Skip to content

Commit d6f6c8c

Browse files
committed
Let a 2025-11-25 tools/call answer with a CreateTaskResult
SEP-1686 makes a task-augmented `tools/call` answer with a `CreateTaskResult`, but `SERVER_RESULTS[("tools/call", "2025-11-25")]` 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. Only the 2025-11-25 row gets the arm. The earlier handshake revisions share `_v2025.CallToolRequest` and so already parse `params.task`, but that is an artifact of the shared schema era, not a licence to answer: they predate SEP-1686, and a clean INTERNAL_ERROR serves an off-spec client better than a result shape its revision never defined. `MONOLITH_RESULTS["tools/call"]` gains the arm too, so `parse_server_result` covers everything the surface admits. `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. The field set resolves on first dump rather than at class creation, because the generated modules defer annotations and finish with `model_rebuild()`, so resolving early would silently see nothing for a forward-referenced field. 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 b61ce38 commit d6f6c8c

17 files changed

Lines changed: 439 additions & 27 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 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)**.
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: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Every section heading below names the API it affects, so searching this page for
3535
| pin dependencies or use the `mcp` CLI | [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli) |
3636
| import `mcp.types` or touch protocol types (everyone does) | [Types and wire format](#types-and-wire-format) |
3737
| run `FastMCP`/`MCPServer` servers | [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) |
38-
| 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 |
38+
| 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 |
3939
| write client code with `Client` or `ClientSession` | [Clients](#clients), plus [`streamablehttp_client` removed](#streamablehttp_client-removed) under Transports |
4040
| use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) |
4141
| maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) |
@@ -1998,11 +1998,59 @@ async def elicitation_callback(
19981998
) -> ElicitResult | ErrorData: ...
19991999
```
20002000

2001-
### Experimental Tasks support removed
2001+
### Experimental Tasks runtime removed
20022002

2003-
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`.
2003+
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`.
20042004

2005-
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.
2005+
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`.
2006+
2007+
```python
2008+
async def call_tool(
2009+
ctx: ServerRequestContext, params: CallToolRequestParams
2010+
) -> CallToolResult | CreateTaskResult:
2011+
if params.task is None or ctx.protocol_version != "2025-11-25":
2012+
return CallToolResult(content=[TextContent(text=run_now())])
2013+
return CreateTaskResult(task=await store.submit(params))
2014+
2015+
2016+
async def get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult:
2017+
return await store.status(params.task_id)
2018+
2019+
2020+
server = Server("example", on_call_tool=call_tool)
2021+
server.add_request_handler("tasks/get", GetTaskRequestParams, get_task)
2022+
```
2023+
2024+
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.
2025+
2026+
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.
2027+
2028+
`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.
2029+
2030+
```python
2031+
class TasksServer(Server[Any]):
2032+
def create_initialization_options(self, *args: Any, **kwargs: Any) -> InitializationOptions:
2033+
options = super().create_initialization_options(*args, **kwargs)
2034+
tasks = ServerTasksCapability(
2035+
list=TasksListCapability(),
2036+
cancel=TasksCancelCapability(),
2037+
requests=ServerTasksRequestsCapability(tools=TasksToolsCapability(call={})),
2038+
)
2039+
return options.model_copy(
2040+
update={"capabilities": options.capabilities.model_copy(update={"tasks": tasks})}
2041+
)
2042+
```
2043+
2044+
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:
2045+
2046+
```python
2047+
result = await client.session.send_request(
2048+
CallToolRequest(params=CallToolRequestParams(name="render", task=TaskMetadata(ttl=60_000))),
2049+
TypeAdapter[CallToolResult | CreateTaskResult](CallToolResult | CreateTaskResult),
2050+
)
2051+
```
2052+
2053+
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.
20062054

20072055
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.
20082056

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 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.shared.version`, `mcp.shared.progress`, and `mcp.shared.session` (with the `RequestResponder` stub v1 `message_handler` annotations imported) as import paths. (`mcp.types` is *not* removed: it remains as a permanent alias for the standalone `mcp_types` package.)
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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@
106106
# Hand-written union aliases the wire-method maps reference by value; the schema
107107
# has no named definition for "everything tools/call may return", so name it here.
108108
EPILOGUES: dict[str, str] = {
109+
# SEP-1686: a task-augmented tools/call answers with a CreateTaskResult, which the
110+
# 2025-11-25 schema says in prose while leaving it out of its own ServerResult union.
111+
"2025-11-25": "AnyCallToolResult = CallToolResult | CreateTaskResult\n",
109112
"2026-07-28": (
110113
"AnyCallToolResult = CallToolResult | InputRequiredResult\n"
111114
"AnyGetPromptResult = GetPromptResult | InputRequiredResult\n"
@@ -219,6 +222,62 @@ def patch(match: re.Match[str]) -> str:
219222
return source
220223

221224

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

src/mcp-types/mcp_types/_types.py

Lines changed: 9 additions & 3 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
@@ -2190,7 +2191,12 @@ def _require_one_field(self) -> Self:
21902191
| SubscriptionsListenResult
21912192
| InputRequiredResult
21922193
)
2193-
"""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.
21942200
21952201
`InputRequiredResult` is deliberately last: both of its fields are optional,
21962202
so an earlier position would shadow other members during union resolution.

src/mcp-types/mcp_types/_v2025_11_25/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from typing import Annotated, Any, Literal
1010

11-
from mcp_types._wire_base import WireModel
11+
from mcp_types._wire_base import KeepRequiredNullable, WireModel
1212
from pydantic import ConfigDict, Field, RootModel
1313

1414

@@ -579,7 +579,7 @@ class LoggingLevel(
579579
"""
580580

581581

582-
class LoggingMessageNotificationParams(WireModel):
582+
class LoggingMessageNotificationParams(WireModel, KeepRequiredNullable):
583583
"""
584584
Parameters for a `notifications/message` notification.
585585
"""
@@ -2517,7 +2517,7 @@ class SubscribeRequest(WireModel):
25172517
params: SubscribeRequestParams
25182518

25192519

2520-
class Task(WireModel):
2520+
class Task(WireModel, KeepRequiredNullable):
25212521
"""
25222522
Data associated with a task.
25232523
"""
@@ -3524,3 +3524,6 @@ class ServerRequest(
35243524
| ListRootsRequest
35253525
| ElicitRequest
35263526
)
3527+
3528+
3529+
AnyCallToolResult = CallToolResult | CreateTaskResult

src/mcp-types/mcp_types/_v2026_07_28/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from typing import Annotated, Any, Literal, Union
1010

11-
from mcp_types._wire_base import WireModel
11+
from mcp_types._wire_base import KeepRequiredNullable, WireModel
1212
from pydantic import ConfigDict, Field, RootModel
1313

1414

@@ -2555,7 +2555,7 @@ class ListToolsResultResponse(WireModel):
25552555
result: ListToolsResult
25562556

25572557

2558-
class LoggingMessageNotificationParams(WireModel):
2558+
class LoggingMessageNotificationParams(WireModel, KeepRequiredNullable):
25592559
"""
25602560
Parameters for a `notifications/message` notification.
25612561
"""

0 commit comments

Comments
 (0)