Skip to content

Commit 269c219

Browse files
committed
Say 2025-11-25 where the docs said handshake-era, and three review fixes
Four review follow-ups, none of them behaviour changes to the task path itself. Three sentences claimed a `CreateTaskResult` answers a task-augmented call on a "handshake-era connection". That term means all four pre-2026 revisions (`HANDSHAKE_PROTOCOL_VERSIONS`), and only 2025-11-25 defines the shape, so the section contradicted its own recipe, which already gates on `ctx.protocol_version`. The earlier revisions still hand `params.task` to the handler, so a server author testing against 2025-06-18, which is what most deployed clients negotiate, would have followed the prose into an opaque internal error. All three now say 2025-11-25 and name why the others reject it. The tasks capability recipe only worked over stdio. It built an `InitializationOptions` to pass to `Server.run()`, but the streamable HTTP manager and the SSE app call `create_initialization_options()` themselves and take no override, so on the SDK's primary transport there was nowhere to put it. Overriding that method instead reaches every transport and is the same length. `_is_excluded` replaces a membership test that could not tell pydantic's `{"field": True}` (drop the field) from `{"field": {"subkey"}}` (keep the field, descend into it), so a required nullable field was left out of a dump that pydantic had kept it in, producing a body that fails its own schema. The two now agree on every include/exclude shape, which a test pins. `ServerResult`'s docstring claimed to be every result payload a server can return. It never was: the five 2025-11-25 task results are all absent, four of them producible today through `add_request_handler`, and the 2025-11-25 schema's own `ServerResult` omits them too. The docstring now records the exclusion the way the sibling unions already do, rather than the union growing one arbitrary arm of the five.
1 parent eb84253 commit 269c219

6 files changed

Lines changed: 48 additions & 25 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
185185
Each of these is one idea you now have the vocabulary for; each has its own page.
186186

187187
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
188-
* `on_call_tool` may also return a `CreateTaskResult` to answer a task-augmented call on a handshake-era connection, which is the one piece of SEP-1686 the SDK still carries; the store and the `tasks/*` handlers are yours to bring, and the 2026-07-28 revision rejects the shape because tasks left its core protocol. See **[Experimental Tasks runtime removed](../migration.md#experimental-tasks-runtime-removed)**.
188+
* `on_call_tool` may also return a `CreateTaskResult` to answer a task-augmented call on a 2025-11-25 connection, which is the one piece of SEP-1686 the SDK still carries; the store and the `tasks/*` handlers are yours to bring, and every other revision rejects the shape, the earlier ones because they never defined it and 2026-07-28 because tasks left its core protocol. See **[Experimental Tasks runtime removed](../migration.md#experimental-tasks-runtime-removed)**.
189189
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
190190
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
191191
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.

docs/migration.md

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,7 +1657,7 @@ Behavior changes:
16571657

16581658
The task runtime that shipped behind the `experimental` properties is gone. 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`. There is no built-in task store, no polling helper, and no automatic `tasks/*` routing. The `TaskExecutionMode` alias is also gone; its literal is inlined on `ToolExecution.task_support`.
16591659

1660-
The task types stay, so a server can still serve Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) on a handshake-era connection by supplying the parts the runtime used to provide. This covers the server side of a task-augmented `tools/call`; the client side of a task-augmented `sampling/createMessage` or `elicitation/create` is not wired, so a client cannot answer one of those with a `CreateTaskResult`. A task-augmented `tools/call` arrives with `params.task` set and may be answered with a `CreateTaskResult`, and the `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` methods are registered with `Server.add_request_handler`.
1660+
The task types stay, so a server can still serve Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) on a 2025-11-25 connection by supplying the parts the runtime used to provide. 2025-11-25 is the only revision that defines them: the earlier handshake revisions predate SEP-1686, so a `CreateTaskResult` there is rejected as an internal error even though `params.task` reaches the handler. This covers the server side of a task-augmented `tools/call`; the client side of a task-augmented `sampling/createMessage` or `elicitation/create` is not wired, so a client cannot answer one of those with a `CreateTaskResult`. A task-augmented `tools/call` arrives with `params.task` set and may be answered with a `CreateTaskResult`, and the `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel` methods are registered with `Server.add_request_handler`.
16611661

16621662
```python
16631663
async def call_tool(
@@ -1680,23 +1680,20 @@ Two things to know about handlers registered this way. They serve every negotiat
16801680

16811681
The same era check belongs in `on_call_tool`, and `params.task` is not a substitute for it. The handler receives the version-free params model, which carries `task` at every version, so a client can set the field on a 2026-07-28 connection and reach a handler that then answers with a `CreateTaskResult`, which that revision rejects as an opaque internal error. Gate on `ctx.protocol_version == "2025-11-25"` and treat `params.task` as the opt-in within that era, not as the era test.
16821682

1683-
`Server.get_capabilities` does not derive a `tasks` capability from the registered handlers, and a spec-compliant client will not augment a request until it sees one, so build the advertisement explicitly:
1684-
1685-
```python
1686-
capabilities = server.get_capabilities()
1687-
options = InitializationOptions(
1688-
server_name="example",
1689-
server_version="0.1.0",
1690-
capabilities=capabilities.model_copy(
1691-
update={
1692-
"tasks": ServerTasksCapability(
1693-
list=TasksListCapability(),
1694-
cancel=TasksCancelCapability(),
1695-
requests=ServerTasksRequestsCapability(tools=TasksToolsCapability(call={})),
1696-
)
1697-
}
1698-
),
1699-
)
1683+
`Server.get_capabilities` does not derive a `tasks` capability from the registered handlers, and a spec-compliant client will not augment a request until it sees one, so add the advertisement by overriding `create_initialization_options`. Override rather than building an `InitializationOptions` and passing it in: `streamable_http_app()` and the SSE app call the method themselves and take no override, so only the subclass reaches every transport.
1684+
1685+
```python
1686+
class TasksServer(Server[Any]):
1687+
def create_initialization_options(self, *args: Any, **kwargs: Any) -> InitializationOptions:
1688+
options = super().create_initialization_options(*args, **kwargs)
1689+
tasks = ServerTasksCapability(
1690+
list=TasksListCapability(),
1691+
cancel=TasksCancelCapability(),
1692+
requests=ServerTasksRequestsCapability(tools=TasksToolsCapability(call={})),
1693+
)
1694+
return options.model_copy(
1695+
update={"capabilities": options.capabilities.model_copy(update={"tasks": tasks})}
1696+
)
17001697
```
17011698

17021699
A client sends the augmented request and names the result type through `ClientSession.send_request`, since `call_tool` resolves to the two core result arms:

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ The renames announce themselves. These do not:
145145
Each of these is a section in the **[Migration Guide](migration.md)**:
146146

147147
* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification.
148-
* The **experimental Tasks** runtime (`mcp.*.experimental`): the task store, the polling helper, and the automatic `tasks/*` routing. The task types stay, so a server can still answer a task-augmented `tools/call` on a handshake-era connection by bringing its own store; 2026-07-28 moves tasks out of the core protocol into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
148+
* The **experimental Tasks** runtime (`mcp.*.experimental`): the task store, the polling helper, and the automatic `tasks/*` routing. The task types stay, so a server can still answer a task-augmented `tools/call` on a 2025-11-25 connection by bringing its own store; 2026-07-28 moves tasks out of the core protocol into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet.
149149
* `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths.
150150
* The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams).
151151
* `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor.

src/mcp-types/mcp_types/_types.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2191,7 +2191,12 @@ def _require_one_field(self) -> Self:
21912191
| SubscriptionsListenResult
21922192
| InputRequiredResult
21932193
)
2194-
"""Union of every result payload a server can return for a client request.
2194+
"""Union of the core result payloads a server can return for a client request.
2195+
2196+
The 2025-11-25 task results (`CreateTaskResult`, `GetTaskResult`,
2197+
`GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`) are deliberately
2198+
excluded, matching that revision's own `ServerResult`; a server serving those
2199+
answers through them directly rather than through this union.
21952200
21962201
`InputRequiredResult` is deliberately last: both of its fields are optional,
21972202
so an earlier position would shadow other members during union resolution.

src/mcp-types/mcp_types/_wire_base.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Shared pydantic bases for the generated `mcp_types.v*` packages and the monolith."""
22

3+
from collections.abc import Container, Mapping
34
from functools import cache
4-
from typing import Any, get_args
5+
from typing import Any, cast, get_args
56

67
from pydantic import BaseModel, ConfigDict, SerializationInfo, SerializerFunctionWrapHandler, model_serializer
78

@@ -64,9 +65,23 @@ def _keep_required_nullable(self, handler: SerializerFunctionWrapHandler, info:
6465
for name, alias in _nullable_required_fields(type(self)):
6566
if getattr(self, name, None) is not None:
6667
continue
67-
if (info.include is not None and name not in info.include) or (
68-
info.exclude is not None and name in info.exclude
69-
):
68+
if info.include is not None and name not in info.include:
69+
continue
70+
if _is_excluded(name, info.exclude):
7071
continue
7172
data.setdefault(alias if by_alias else name, None)
7273
return data
74+
75+
76+
def _is_excluded(name: str, exclude: Any) -> bool:
77+
"""Whether `exclude` drops `name` outright, as opposed to selecting within it.
78+
79+
A mapping entry carrying anything other than `True`/`...` descends into the field, so
80+
pydantic keeps the field itself and its null still has to go back.
81+
"""
82+
if exclude is None:
83+
return False
84+
if isinstance(exclude, Mapping):
85+
marker: Any = cast("Mapping[Any, Any]", exclude).get(name)
86+
return marker is True or marker is Ellipsis
87+
return name in cast("Container[Any]", exclude)

tests/types/test_parity.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,13 @@ def test_keep_required_nullable_only_restores_what_exclude_none_removed() -> Non
291291

292292
assert task.model_dump(by_alias=True, exclude_none=True)["ttl"] is None
293293
assert "ttl" not in task.model_dump(by_alias=True, exclude_none=True, exclude={"ttl"})
294+
assert "ttl" not in task.model_dump(by_alias=True, exclude_none=True, exclude={"ttl": True})
294295
assert task.model_dump(by_alias=True, exclude_none=True, include={"task_id"}) == {"taskId": "t1"}
296+
# A mapping entry that is not `True`/`...` selects within the field, so pydantic keeps the
297+
# field itself and the null has to go back. `data` is `Any`, so descending into it is legal.
298+
log = _types.LoggingMessageNotificationParams(level="info", data=None)
299+
assert log.model_dump(exclude_none=True, exclude={"data": {"secret"}}) == {"level": "info", "data": None}
300+
assert log.model_dump(exclude_none=True, exclude={"data": True}) == {"level": "info"}
295301
# Without exclude_none nothing was dropped, so the wrap passes the dump through untouched.
296302
assert task.model_dump(by_alias=True)["statusMessage"] is None
297303
# The restored key follows the caller's alias choice rather than always using the wire spelling.

0 commit comments

Comments
 (0)