Skip to content

Commit 5b4fcec

Browse files
committed
Let a legacy-era tools/call answer with a CreateTaskResult
SEP-1686 makes a task-augmented `tools/call` answer with a `CreateTaskResult`, but the `tools/call` result rows held `CallToolResult` alone. The request half shipped and the response half did not, so a server returning a task got `-32603 "Handler returned an invalid result"`, a server returning both shapes had `task` silently sieved away, and a client receiving one raised `ValidationError` before its caller saw anything. The v1.x line could express that response; v2 cannot, on a revision the SDK still negotiates and still lets a server advertise `capabilities.tasks` for. Give the row its second arm as a generated `AnyCallToolResult`, alongside the 2026 aliases, and widen the lowlevel `Server`'s `on_call_tool` to match. Return types are covariant, so a handler that only returns `CallToolResult` is unaffected. All four pre-2026 versions get the arm, because all four share `v2025.CallToolRequest` and therefore already parse `params.task`: answering it at one of them and not the others is the same incoherence one version along. `Task.ttl` is required and nullable ("null for unlimited"), and every dump path passes `exclude_none=True`, which cannot tell a required null from an unset optional and dropped it, leaving a body that fails the surface it was just validated against. Rather than patch the 27 dump sites, models carrying such a field now take a `KeepRequiredNullable` base that puts it back, and only that: a field the caller filtered out with `include`/`exclude`, or one that was never set, stays absent. The generator derives which classes need it from the schema and a test asserts the same rule against the built models, so a nullable-required field in a future revision is covered without anyone remembering. That rule also reaches two live bugs of the same shape. `LoggingMessageNotificationParams.data` is required and untyped, so `ctx.log("info", None)` dropped the key and the receiving session rejected the notification before dispatch: the message vanished with no error to either side, at every protocol version. And `JSONRPCError.id` is required-and-nullable per JSON-RPC 2.0, which the streamable-HTTP writer worked around by hand at one call site; that patch is deleted and the base covers every writer. The base is applied per model rather than to `MCPModel`/`WireModel`: a wrap serializer costs per dump and pushes pydantic off its fast path for every model in the tree, which measured 5x on a 20-tool `tools/list`. Its return is deliberately unannotated, because an annotation there collapses the model's serialization JSON schema to an opaque object. The `tasks/*` lifecycle methods are left where they already work. They are absent from `SPEC_CLIENT_METHODS`, so `Server.add_request_handler` serves them today; adding registry rows would put those names into that version-flattened set, which would make it illegal for an extension to bind `tasks/get` and would gate the 2026 tasks extension's own calls to METHOD_NOT_FOUND. 2026-07-28 keeps rejecting a `CreateTaskResult` on `tools/call`, which is correct: tasks left the core protocol there.
1 parent 00a7014 commit 5b4fcec

17 files changed

Lines changed: 422 additions & 32 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +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)**.
188189
* `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.
189190
* `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.
190191
* `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: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Every section heading below names the API it affects, so searching this page for
3333
| pin dependencies or use the `mcp` CLI | [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli) |
3434
| import `mcp.types` or touch protocol types (everyone does) | [Types and wire format](#types-and-wire-format) |
3535
| run `FastMCP`/`MCPServer` servers | [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) |
36-
| use the lowlevel `Server` | [Lowlevel Server](#lowlevel-server), plus [Timeouts take `float` seconds](#timeouts-take-float-seconds-instead-of-timedelta) and [Experimental Tasks support removed](#experimental-tasks-support-removed) under Clients |
36+
| use the lowlevel `Server` | [Lowlevel Server](#lowlevel-server), plus [Timeouts take `float` seconds](#timeouts-take-float-seconds-instead-of-timedelta) and [Experimental Tasks runtime removed](#experimental-tasks-runtime-removed) under Clients |
3737
| write client code with `Client` or `ClientSession` | [Clients](#clients), plus [`streamablehttp_client` removed](#streamablehttp_client-removed) under Transports |
3838
| use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) |
3939
| maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) |
@@ -1653,11 +1653,62 @@ Behavior changes:
16531653

16541654
`mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists, and neither do the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) or `BaseSession._in_flight`; inbound cancellation is handled by `JSONRPCDispatcher`.
16551655

1656-
### Experimental Tasks support removed
1656+
### Experimental Tasks runtime removed
16571657

1658-
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`.
1658+
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 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
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`.
1661+
1662+
```python
1663+
async def call_tool(
1664+
ctx: ServerRequestContext, params: CallToolRequestParams
1665+
) -> CallToolResult | CreateTaskResult:
1666+
if params.task is None:
1667+
return CallToolResult(content=[TextContent(text=run_now())])
1668+
return CreateTaskResult(task=await store.submit(params))
1669+
1670+
1671+
async def get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult:
1672+
return await store.status(params.task_id)
1673+
1674+
1675+
server = Server("example", on_call_tool=call_tool)
1676+
server.add_request_handler("tasks/get", GetTaskRequestParams, get_task)
1677+
```
1678+
1679+
Two things to know about handlers registered this way. They serve every negotiated version, so a server that also answers 2026-era clients should check `ctx.protocol_version` and reject anything outside 2025-11-25; the method names collide with the 2026 tasks extension but the payloads are not compatible. And their results are not validated against a per-version surface, so raise `MCPError` for the failure cases rather than letting an exception escape: an unhandled one reaches the client as an unmapped error carrying the exception text.
1680+
1681+
The same era check belongs in `on_call_tool`. Its return type admits a `CreateTaskResult` at every version because the signature has no version to key on, but 2026-07-28 dropped tasks from the core protocol and rejects the shape, turning it into an opaque internal error. Returning one only when `params.task` is set keeps that unreachable for a well-behaved client, since a client that never learned the field never sets it.
1682+
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+
)
1700+
```
1701+
1702+
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:
1703+
1704+
```python
1705+
result = await client.session.send_request(
1706+
CallToolRequest(params=CallToolRequestParams(name="render", task=TaskMetadata(ttl=60_000))),
1707+
TypeAdapter[CallToolResult | CreateTaskResult](CallToolResult | CreateTaskResult),
1708+
)
1709+
```
1710+
1711+
The 2026-07-28 revision drops Tasks from the core protocol and reintroduces them as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. It is a different protocol, not a rename, and this SDK does not implement it yet.
16611712

16621713
## Transports
16631714

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** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and 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 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.
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.

scripts/gen_surface_types.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@
105105
# Hand-written union aliases the wire-method maps reference by value; the schema
106106
# has no named definition for "everything tools/call may return", so name it here.
107107
EPILOGUES: dict[str, str] = {
108+
# SEP-1686: a task-augmented tools/call answers with a CreateTaskResult, which the
109+
# 2025-11-25 schema says in prose while leaving it out of its own ServerResult union.
110+
"2025-11-25": "AnyCallToolResult = CallToolResult | CreateTaskResult\n",
108111
"2026-07-28": (
109112
"AnyCallToolResult = CallToolResult | InputRequiredResult\n"
110113
"AnyGetPromptResult = GetPromptResult | InputRequiredResult\n"
@@ -218,6 +221,58 @@ def patch(match: re.Match[str]) -> str:
218221
return source
219222

220223

224+
def nullable_required_classes(schema: dict[str, Any]) -> frozenset[str]:
225+
"""`$defs` entries with a required property whose value may be null.
226+
227+
`exclude_none=True` drops such a field, producing a body that fails its own schema, so
228+
these classes take `KeepRequiredNullable` as a second base. Derived rather than listed,
229+
so a new one in a future revision is covered by regenerating.
230+
231+
This reads each `$def`'s own `required` list; a class that inherits the field through
232+
composition is covered because codegen renders the composition as a Python base. The
233+
authority is `tests/types/test_parity.py`, which applies the same rule to the built
234+
models, so anything this misses fails the suite rather than the wire.
235+
"""
236+
return frozenset(
237+
name
238+
for name, definition in schema.get("$defs", {}).items()
239+
for prop in definition.get("required", [])
240+
if _admits_null(definition.get("properties", {}).get(prop, {}))
241+
)
242+
243+
244+
def _admits_null(prop: dict[str, Any]) -> bool:
245+
"""Whether `prop` permits a null value: declared as such, composed with null, or unconstrained."""
246+
declared = prop.get("type", ())
247+
types = {declared} if isinstance(declared, str) else set(declared)
248+
if "null" in types:
249+
return True
250+
# `anyOf: [{$ref: ...}, {type: null}]` is how a nullable reference renders.
251+
if any(_admits_null(arm) for arm in (*prop.get("anyOf", ()), *prop.get("oneOf", ()))):
252+
return True
253+
# No type and no composition keyword at all means any JSON value, null included.
254+
return not types and not any(key in prop for key in ("anyOf", "oneOf", "allOf", "$ref", "enum", "const"))
255+
256+
257+
def keep_required_nullable(source: str, classes: frozenset[str]) -> str:
258+
"""Give each of `classes` `KeepRequiredNullable` as a second base."""
259+
for name in sorted(classes):
260+
source, count = re.subn(
261+
rf"^class {name}\(WireModel\):$",
262+
f"class {name}(WireModel, KeepRequiredNullable):",
263+
source,
264+
flags=re.MULTILINE,
265+
)
266+
if count != 1:
267+
raise SystemExit(f"expected one `class {name}(WireModel)` to patch, found {count}")
268+
if classes:
269+
source = source.replace(
270+
"from mcp_types._wire_base import WireModel",
271+
"from mcp_types._wire_base import KeepRequiredNullable, WireModel",
272+
)
273+
return source
274+
275+
221276
def build(entry: dict[str, str]) -> str:
222277
"""Generate, post-process, and format one version's surface module text."""
223278
version = entry["protocol_version"]
@@ -242,6 +297,7 @@ def build(entry: dict[str, str]) -> str:
242297
# strict mkdocs link validation.
243298
source = source.replace("](/", "](https://modelcontextprotocol.io/")
244299
source = allow_open_class_extras(source, OPEN_CLASSES[version])
300+
source = keep_required_nullable(source, nullable_required_classes(schema))
245301
if epilogue := EPILOGUES.get(version, ""):
246302
# Insert before the trailing model_rebuild() block: pyright's evaluation
247303
# order for the recursive RootModel block is sensitive to placement.

src/mcp-types/mcp_types/_types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from pydantic.alias_generators import to_camel
2222
from typing_extensions import NotRequired, Self, TypedDict
2323

24+
from mcp_types._wire_base import KeepRequiredNullable
2425
from mcp_types.jsonrpc import RequestId
2526

2627
DEFAULT_NEGOTIATED_VERSION: Final[str] = "2025-03-26"
@@ -636,7 +637,7 @@ class RelatedTaskMetadata(MCPModel):
636637
"""The status of a task (2025-11-25 only)."""
637638

638639

639-
class Task(MCPModel):
640+
class Task(MCPModel, KeepRequiredNullable):
640641
"""Data associated with a task (2025-11-25 only)."""
641642

642643
task_id: str
@@ -1526,7 +1527,7 @@ class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"]
15261527
params: SetLevelRequestParams
15271528

15281529

1529-
class LoggingMessageNotificationParams(NotificationParams):
1530+
class LoggingMessageNotificationParams(NotificationParams, KeepRequiredNullable):
15301531
level: LoggingLevel
15311532
"""The severity of this log message."""
15321533
logger: str | None = None
Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,81 @@
1-
"""Shared pydantic base for the generated `mcp_types.v*` wire-shape packages."""
1+
"""Shared pydantic bases for the generated `mcp_types.v*` packages and the monolith."""
22

3-
from pydantic import BaseModel, ConfigDict
3+
from typing import Any, ClassVar, Final, get_args
4+
5+
from pydantic import BaseModel, ConfigDict, SerializationInfo, SerializerFunctionWrapHandler, model_serializer
6+
7+
_UNSET: Final[object] = object()
8+
"""Tells a field explicitly set to `None` apart from one that was never set."""
49

510

611
class WireModel(BaseModel):
712
"""Base for generated wire models: enables `populate_by_name`; subclasses set `extra` themselves."""
813

914
model_config = ConfigDict(populate_by_name=True)
15+
16+
17+
class KeepRequiredNullable(BaseModel):
18+
"""Base for models carrying a required nullable field, e.g. `Task.ttl` (`number | null`).
19+
20+
Every dump path passes `exclude_none=True` to omit unset optionals, but that cannot tell an
21+
unset optional from a required field whose value is legitimately null, so it drops both and
22+
leaves a body that fails the schema it was just validated against. This puts the required
23+
ones back, and only those: a field the caller filtered out with `include`/`exclude`, or one
24+
that was never set at all, stays absent.
25+
26+
Mixed in only where it is needed, since a wrap serializer costs per dump:
27+
`scripts/gen_surface_types.py` derives the surface classes from the schema and the monolith
28+
counterparts take it by hand, with `tests/types/test_parity.py` checking the set is complete.
29+
"""
30+
31+
_nullable_required_fields: ClassVar[tuple[tuple[str, str], ...] | None] = None
32+
"""`(attribute, wire alias)` per required nullable field; resolved on first dump, then cached."""
33+
34+
@classmethod
35+
def _resolve_nullable_required(cls) -> tuple[tuple[str, str], ...]:
36+
"""Find the fields `exclude_none` must not drop, once per concrete class.
37+
38+
Deferred to first use rather than `__pydantic_init_subclass__`: the generated modules
39+
use `from __future__ import annotations` and finish with `model_rebuild()`, so a forward
40+
reference is still a string at class-creation time and would resolve to nothing. Each
41+
class resolves its own set, since subclasses add fields (`GetTaskResult` is `Result`
42+
plus `Task`).
43+
"""
44+
resolved = tuple(
45+
(name, field.serialization_alias or field.alias or name)
46+
for name, field in cls.model_fields.items()
47+
if field.is_required() and _admits_none(field.annotation)
48+
)
49+
cls._nullable_required_fields = resolved
50+
return resolved
51+
52+
@model_serializer(mode="wrap")
53+
def _keep_required_nullable(self, handler: SerializerFunctionWrapHandler, info: SerializationInfo):
54+
# The return is deliberately unannotated: pydantic builds the serialization JSON schema
55+
# from this signature, and any annotation collapses the whole model's schema to an
56+
# opaque object for anyone generating schemas over these types.
57+
data = handler(self)
58+
if not info.exclude_none:
59+
return data
60+
# `__dict__`, not attribute lookup: a subclass must resolve its own set rather than
61+
# inherit whichever ancestor happened to be dumped first.
62+
cls = type(self)
63+
fields = cls.__dict__.get("_nullable_required_fields")
64+
if fields is None:
65+
fields = cls._resolve_nullable_required()
66+
for name, alias in fields:
67+
if self.__dict__.get(name, _UNSET) is not None:
68+
continue
69+
if (info.include is not None and name not in info.include) or (
70+
info.exclude is not None and name in info.exclude
71+
):
72+
continue # the caller filtered this field out; exclude_none is not why it is gone
73+
data.setdefault(alias if info.by_alias else name, None)
74+
return data
75+
76+
77+
def _admits_none(annotation: Any) -> bool:
78+
"""Whether a field's annotation accepts `None`, including a bare `Any`."""
79+
if annotation is Any or annotation is None:
80+
return True
81+
return type(None) in get_args(annotation)

src/mcp-types/mcp_types/jsonrpc.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from pydantic import BaseModel, Field, TypeAdapter
88

9+
from mcp_types._wire_base import KeepRequiredNullable
10+
911
RequestId = Annotated[int, Field(strict=True)] | str
1012
"""The ID of a JSON-RPC request."""
1113

@@ -106,7 +108,7 @@ class ErrorData(BaseModel):
106108
"""
107109

108110

109-
class JSONRPCError(BaseModel):
111+
class JSONRPCError(KeepRequiredNullable):
110112
"""A response to a request that indicates an error occurred."""
111113

112114
jsonrpc: Literal["2.0"]

0 commit comments

Comments
 (0)