diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index dd5fb427a2..44c6af6376 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -185,6 +185,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other Each of these is one idea you now have the vocabulary for; each has its own page. * `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)**). +* `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)**. * `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. * `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. * `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. diff --git a/docs/migration.md b/docs/migration.md index 2f2594c4c9..8b03a5fb6b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -33,7 +33,7 @@ Every section heading below names the API it affects, so searching this page for | pin dependencies or use the `mcp` CLI | [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli) | | import `mcp.types` or touch protocol types (everyone does) | [Types and wire format](#types-and-wire-format) | | run `FastMCP`/`MCPServer` servers | [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) | -| 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 | +| 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 | | write client code with `Client` or `ClientSession` | [Clients](#clients), plus [`streamablehttp_client` removed](#streamablehttp_client-removed) under Transports | | use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) | | maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) | @@ -1653,11 +1653,59 @@ Behavior changes: `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`. -### Experimental Tasks support removed +### Experimental Tasks runtime removed -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`. +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`. -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. +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`. + +```python +async def call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams +) -> CallToolResult | CreateTaskResult: + if params.task is None or ctx.protocol_version != "2025-11-25": + return CallToolResult(content=[TextContent(text=run_now())]) + return CreateTaskResult(task=await store.submit(params)) + + +async def get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: + return await store.status(params.task_id) + + +server = Server("example", on_call_tool=call_tool) +server.add_request_handler("tasks/get", GetTaskRequestParams, get_task) +``` + +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. + +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. + +`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. + +```python +class TasksServer(Server[Any]): + def create_initialization_options(self, *args: Any, **kwargs: Any) -> InitializationOptions: + options = super().create_initialization_options(*args, **kwargs) + tasks = ServerTasksCapability( + list=TasksListCapability(), + cancel=TasksCancelCapability(), + requests=ServerTasksRequestsCapability(tools=TasksToolsCapability(call={})), + ) + return options.model_copy( + update={"capabilities": options.capabilities.model_copy(update={"tasks": tasks})} + ) +``` + +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: + +```python +result = await client.session.send_request( + CallToolRequest(params=CallToolRequestParams(name="render", task=TaskMetadata(ttl=60_000))), + TypeAdapter[CallToolResult | CreateTaskResult](CallToolResult | CreateTaskResult), +) +``` + +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. ## Transports diff --git a/docs/whats-new.md b/docs/whats-new.md index 3f1188f8fc..134f00cfd7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -145,7 +145,7 @@ The renames announce themselves. These do not: Each of these is a section in the **[Migration Guide](migration.md)**: * The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification. -* 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. +* 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. * `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths. * The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams). * `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor. diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index 0c7b85289b..ce50f10254 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -105,6 +105,9 @@ # Hand-written union aliases the wire-method maps reference by value; the schema # has no named definition for "everything tools/call may return", so name it here. EPILOGUES: dict[str, str] = { + # SEP-1686: a task-augmented tools/call answers with a CreateTaskResult, which the + # 2025-11-25 schema says in prose while leaving it out of its own ServerResult union. + "2025-11-25": "AnyCallToolResult = CallToolResult | CreateTaskResult\n", "2026-07-28": ( "AnyCallToolResult = CallToolResult | InputRequiredResult\n" "AnyGetPromptResult = GetPromptResult | InputRequiredResult\n" @@ -218,6 +221,62 @@ def patch(match: re.Match[str]) -> str: return source +def nullable_required_classes(schema: dict[str, Any]) -> frozenset[str]: + """`$defs` entries with a required property whose value may be null. + + `exclude_none=True` drops such a field, producing a body that fails its own schema, so + these classes take `KeepRequiredNullable` as a second base. Derived rather than listed, + so a new one in a future revision is covered by regenerating. + + This reads each `$def`'s own `required` list; a class that inherits the field through + composition is covered because codegen renders the composition as a Python base. The + authority is `tests/types/test_parity.py`, which applies the same rule to the built + models, so anything this misses fails the suite rather than the wire. + """ + return frozenset( + name + for name, definition in schema.get("$defs", {}).items() + for prop in definition.get("required", []) + if _admits_null(definition.get("properties", {}).get(prop, {})) + ) + + +def _admits_null(prop: dict[str, Any]) -> bool: + """Whether `prop` permits a null value: declared as such, composed with null, or unconstrained.""" + declared = prop.get("type", ()) + types = {declared} if isinstance(declared, str) else set(declared) + if "null" in types: + return True + # `anyOf: [{$ref: ...}, {type: null}]` is how a nullable reference renders. + if any(_admits_null(arm) for arm in (*prop.get("anyOf", ()), *prop.get("oneOf", ()))): + return True + # No type and no composition keyword at all means any JSON value, null included. + return not types and not any(key in prop for key in ("anyOf", "oneOf", "allOf", "$ref", "enum", "const")) + + +def keep_required_nullable(source: str, classes: frozenset[str]) -> str: + """Append `KeepRequiredNullable` to each of `classes`'s base list. + + Matches whatever bases codegen chose, since a `$def` that composes through `allOf` is + emitted with its composed bases rather than a bare `WireModel`. + """ + for name in sorted(classes): + source, count = re.subn( + rf"^class {name}\((?P[^)]+)\):$", + rf"class {name}(\g, KeepRequiredNullable):", + source, + flags=re.MULTILINE, + ) + if count != 1: + raise SystemExit(f"expected one `class {name}(...)` to patch, found {count}") + if classes: + import_line = "from mcp_types._wire_base import WireModel" + if import_line not in source: + raise SystemExit(f"cannot import KeepRequiredNullable: {import_line!r} not found") + source = source.replace(import_line, "from mcp_types._wire_base import KeepRequiredNullable, WireModel") + return source + + def build(entry: dict[str, str]) -> str: """Generate, post-process, and format one version's surface module text.""" version = entry["protocol_version"] @@ -242,6 +301,7 @@ def build(entry: dict[str, str]) -> str: # strict mkdocs link validation. source = source.replace("](/", "](https://modelcontextprotocol.io/") source = allow_open_class_extras(source, OPEN_CLASSES[version]) + source = keep_required_nullable(source, nullable_required_classes(schema)) if epilogue := EPILOGUES.get(version, ""): # Insert before the trailing model_rebuild() block: pyright's evaluation # order for the recursive RootModel block is sensitive to placement. diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index e19ebe0b94..4c20ff5d53 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -21,6 +21,7 @@ from pydantic.alias_generators import to_camel from typing_extensions import NotRequired, Self, TypedDict +from mcp_types._wire_base import KeepRequiredNullable from mcp_types.jsonrpc import RequestId DEFAULT_NEGOTIATED_VERSION: Final[str] = "2025-03-26" @@ -636,7 +637,7 @@ class RelatedTaskMetadata(MCPModel): """The status of a task (2025-11-25 only).""" -class Task(MCPModel): +class Task(MCPModel, KeepRequiredNullable): """Data associated with a task (2025-11-25 only).""" task_id: str @@ -1526,7 +1527,7 @@ class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"] params: SetLevelRequestParams -class LoggingMessageNotificationParams(NotificationParams): +class LoggingMessageNotificationParams(NotificationParams, KeepRequiredNullable): level: LoggingLevel """The severity of this log message.""" logger: str | None = None @@ -2190,7 +2191,12 @@ def _require_one_field(self) -> Self: | SubscriptionsListenResult | InputRequiredResult ) -"""Union of every result payload a server can return for a client request. +"""Union of the core result payloads a server can return for a client request. + +The 2025-11-25 task results (`CreateTaskResult`, `GetTaskResult`, +`GetTaskPayloadResult`, `ListTasksResult`, `CancelTaskResult`) are deliberately +excluded, matching that revision's own `ServerResult`; a server serving those +answers through them directly rather than through this union. `InputRequiredResult` is deliberately last: both of its fields are optional, so an earlier position would shadow other members during union resolution. diff --git a/src/mcp-types/mcp_types/_wire_base.py b/src/mcp-types/mcp_types/_wire_base.py index 8d7b09d7f3..a74182ba40 100644 --- a/src/mcp-types/mcp_types/_wire_base.py +++ b/src/mcp-types/mcp_types/_wire_base.py @@ -1,9 +1,87 @@ -"""Shared pydantic base for the generated `mcp_types.v*` wire-shape packages.""" +"""Shared pydantic bases for the generated `mcp_types.v*` packages and the monolith.""" -from pydantic import BaseModel, ConfigDict +from collections.abc import Container, Mapping +from functools import cache +from typing import Any, cast, get_args + +from pydantic import BaseModel, ConfigDict, SerializationInfo, SerializerFunctionWrapHandler, model_serializer class WireModel(BaseModel): """Base for generated wire models: enables `populate_by_name`; subclasses set `extra` themselves.""" model_config = ConfigDict(populate_by_name=True) + + +def admits_none(annotation: Any) -> bool: + """Whether a field's annotation accepts `None`, including a bare `Any`.""" + return annotation is Any or annotation is None or type(None) in get_args(annotation) + + +@cache +def _nullable_required_fields(model: type[BaseModel]) -> tuple[tuple[str, str], ...]: + """The `(attribute, wire alias)` pairs `exclude_none` must not drop from `model`. + + Resolved on first dump rather than at class creation: the generated modules use + `from __future__ import annotations` and finish with `model_rebuild()`, so a forward + reference is still a string while the class body runs and would resolve to nothing. + Keyed on the concrete class, since subclasses add fields (`GetTaskResult` is `Result` + plus `Task`). + """ + return tuple( + (name, field.serialization_alias or field.alias or name) + for name, field in model.model_fields.items() + if field.is_required() and admits_none(field.annotation) + ) + + +class KeepRequiredNullable(BaseModel): + """Base for models carrying a required nullable field, e.g. `Task.ttl` (`number | null`). + + Every dump path passes `exclude_none=True` to omit unset optionals, but that cannot tell an + unset optional from a required field whose value is legitimately null, so it drops both and + leaves a body that fails the schema it was just validated against. This puts the required + ones back, and only those: a field the caller filtered out with `include`/`exclude` stays + absent, because there `exclude_none` is not why it went. + + Mixed in only where it is needed, since a wrap serializer costs per dump and pydantic walks + one per model in the tree: `scripts/gen_surface_types.py` derives the surface classes from + the schema and the monolith counterparts take it by hand, with `tests/types/test_parity.py` + checking the resulting set against every built model. + """ + + @model_serializer(mode="wrap") + def _keep_required_nullable(self, handler: SerializerFunctionWrapHandler, info: SerializationInfo): + # The return is deliberately unannotated: pydantic builds the serialization JSON schema + # from this signature, and any annotation collapses the whole model's schema to an + # opaque object for anyone generating schemas over these types. + data = handler(self) + if not info.exclude_none: + return data + # `serialize_by_alias` is the config-level spelling of the `by_alias` argument; reading + # only the argument would restore the one key under a spelling the rest of the dump did + # not use. + by_alias = info.by_alias or type(self).model_config.get("serialize_by_alias", False) + for name, alias in _nullable_required_fields(type(self)): + if getattr(self, name, None) is not None: + continue + if info.include is not None and name not in info.include: + continue + if _is_excluded(name, info.exclude): + continue + data.setdefault(alias if by_alias else name, None) + return data + + +def _is_excluded(name: str, exclude: Any) -> bool: + """Whether `exclude` drops `name` outright, as opposed to selecting within it. + + A mapping entry carrying anything other than `True`/`...` descends into the field, so + pydantic keeps the field itself and its null still has to go back. + """ + if exclude is None: + return False + if isinstance(exclude, Mapping): + marker: Any = cast("Mapping[Any, Any]", exclude).get(name) + return marker is True or marker is Ellipsis + return name in cast("Container[Any]", exclude) diff --git a/src/mcp-types/mcp_types/jsonrpc.py b/src/mcp-types/mcp_types/jsonrpc.py index fcc3317d86..5f191bac83 100644 --- a/src/mcp-types/mcp_types/jsonrpc.py +++ b/src/mcp-types/mcp_types/jsonrpc.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, Field, TypeAdapter +from mcp_types._wire_base import KeepRequiredNullable + RequestId = Annotated[int, Field(strict=True)] | str """The ID of a JSON-RPC request.""" @@ -106,7 +108,7 @@ class ErrorData(BaseModel): """ -class JSONRPCError(BaseModel): +class JSONRPCError(KeepRequiredNullable): """A response to a request that indicates an error occurred.""" jsonrpc: Literal["2.0"] diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 37e1145386..415f4c226c 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -286,7 +286,7 @@ ("resources/subscribe", "2025-11-25"): v2025.EmptyResult, ("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesResult, ("resources/unsubscribe", "2025-11-25"): v2025.EmptyResult, - ("tools/call", "2025-11-25"): v2025.CallToolResult, + ("tools/call", "2025-11-25"): v2025.AnyCallToolResult, ("tools/list", "2025-11-25"): v2025.ListToolsResult, # 2026-07-28 (dual-result rows use the version's union aliases) ("completion/complete", "2026-07-28"): v2026.CompleteResult, @@ -401,7 +401,7 @@ "sampling/createMessage": types.CreateMessageResult | types.CreateMessageResultWithTools, "server/discover": types.DiscoverResult, "subscriptions/listen": types.SubscriptionsListenResult, - "tools/call": types.CallToolResult | types.InputRequiredResult, + "tools/call": types.CallToolResult | types.InputRequiredResult | types.CreateTaskResult, "tools/list": types.ListToolsResult, } ) diff --git a/src/mcp-types/mcp_types/v2025_11_25/__init__.py b/src/mcp-types/mcp_types/v2025_11_25/__init__.py index b639bf7af8..a4cb5d4bdc 100644 --- a/src/mcp-types/mcp_types/v2025_11_25/__init__.py +++ b/src/mcp-types/mcp_types/v2025_11_25/__init__.py @@ -8,7 +8,7 @@ from typing import Annotated, Any, Literal -from mcp_types._wire_base import WireModel +from mcp_types._wire_base import KeepRequiredNullable, WireModel from pydantic import ConfigDict, Field, RootModel @@ -579,7 +579,7 @@ class LoggingLevel( """ -class LoggingMessageNotificationParams(WireModel): +class LoggingMessageNotificationParams(WireModel, KeepRequiredNullable): """ Parameters for a `notifications/message` notification. """ @@ -2517,7 +2517,7 @@ class SubscribeRequest(WireModel): params: SubscribeRequestParams -class Task(WireModel): +class Task(WireModel, KeepRequiredNullable): """ Data associated with a task. """ @@ -3524,3 +3524,6 @@ class ServerRequest( | ListRootsRequest | ElicitRequest ) + + +AnyCallToolResult = CallToolResult | CreateTaskResult diff --git a/src/mcp-types/mcp_types/v2026_07_28/__init__.py b/src/mcp-types/mcp_types/v2026_07_28/__init__.py index fb168b3059..470a89d1c0 100644 --- a/src/mcp-types/mcp_types/v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/v2026_07_28/__init__.py @@ -8,7 +8,7 @@ from typing import Annotated, Any, Literal, Union -from mcp_types._wire_base import WireModel +from mcp_types._wire_base import KeepRequiredNullable, WireModel from pydantic import ConfigDict, Field, RootModel @@ -2555,7 +2555,7 @@ class ListToolsResultResponse(WireModel): result: ListToolsResult -class LoggingMessageNotificationParams(WireModel): +class LoggingMessageNotificationParams(WireModel, KeepRequiredNullable): """ Parameters for a `notifications/message` notification. """ diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 1db6b35f8a..a73a4dedab 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -185,10 +185,6 @@ async def _write( """Serialise a JSON-RPC reply with the table-mapped HTTP status.""" status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS body = msg.model_dump(mode="json", by_alias=True, exclude_none=True) - if isinstance(msg, JSONRPCError) and msg.id is None: - # JSON-RPC requires `id: null` to appear on the wire when the request - # id couldn't be parsed; `exclude_none` would otherwise drop it. - body["id"] = None await Response( json.dumps(body, separators=(",", ":")), status_code=status, diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 1cbd3f2bd6..4ca901a664 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -150,7 +150,7 @@ def __init__( | None = None, on_call_tool: Callable[ [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], - Awaitable[types.CallToolResult | types.InputRequiredResult], + Awaitable[types.CallToolResult | types.InputRequiredResult | types.CreateTaskResult], ] | None = None, on_list_resources: Callable[ @@ -233,7 +233,7 @@ def __init__( | None = None, on_call_tool: Callable[ [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], - Awaitable[types.CallToolResult | types.InputRequiredResult], + Awaitable[types.CallToolResult | types.InputRequiredResult | types.CreateTaskResult], ] | None = None, on_list_resources: Callable[ @@ -325,7 +325,7 @@ def __init__( | None = None, on_call_tool: Callable[ [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], - Awaitable[types.CallToolResult | types.InputRequiredResult], + Awaitable[types.CallToolResult | types.InputRequiredResult | types.CreateTaskResult], ] | None = None, on_list_resources: Callable[ diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index a9fa7ea87f..2380e84984 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -827,6 +827,21 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/server/tools#text-content", behavior="tools/call delivers arguments to the tool handler and returns its text content to the caller.", ), + "tools:call:task-augmented": Requirement( + source=f"{SPEC_BASE_URL}/basic/utilities/tasks#creating-tasks", + behavior=( + "A tools/call carrying `task` may be answered with a CreateTaskResult instead of a " + "CallToolResult, and the task reaches the caller intact." + ), + added_in="2025-11-25", + removed_in="2026-07-28", + note=( + "SEP-1686, removed from core in 2026-07-28 in favour of the io.modelcontextprotocol/tasks " + "extension. The SDK ships the wire vocabulary and the result arm; the task store, the " + "tasks/* lifecycle handlers and the capability ad are the server author's to provide " + "(tasks/* are servable through Server.add_request_handler)." + ), + ), "tools:call:concurrent": Requirement( source="sdk", behavior=( @@ -1509,6 +1524,13 @@ def __post_init__(self) -> None: "severity level, logger name, and data." ), ), + "logging:message:null-data": Requirement( + source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications", + behavior=( + "`data` is a required field that accepts any JSON value, so a log message whose data is " + "null is delivered like any other rather than dropped." + ), + ), "logging:message:filtered": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", behavior="After logging/setLevel, log messages below the configured level are not sent.", diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py index 7f2f89fd1a..a320d9f887 100644 --- a/tests/interaction/lowlevel/test_logging.py +++ b/tests/interaction/lowlevel/test_logging.py @@ -126,3 +126,35 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq await client.call_tool("siren", {}) assert [params.level for params in received] == list(ALL_LEVELS) + + +@requirement("logging:message:null-data") +async def test_log_message_with_null_data_reaches_the_logging_callback(connect: Connect) -> None: + """`data` is required but accepts any JSON value, so a null payload is a message, not an omission. + + Spec-mandated: dropping the key produces a notification that fails its own schema, which the + receiving session rejects before dispatch, so the message disappears with no error to either side. + """ + received: list[LoggingMessageNotificationParams] = [] + + async def collect(params: LoggingMessageNotificationParams) -> None: + received.append(params) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="quiet", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "quiet" + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", data=None, related_request_id=ctx.request_id + ) + return CallToolResult(content=[TextContent(text="done")]) + + server = Server("logger", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server, logging_callback=collect) as client: + await client.call_tool("quiet", {}) + + assert received == snapshot([LoggingMessageNotificationParams(level="info", data=None)]) diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py index 86fec356bc..d30c7e128c 100644 --- a/tests/interaction/lowlevel/test_tools.py +++ b/tests/interaction/lowlevel/test_tools.py @@ -19,6 +19,7 @@ Tool, ToolAnnotations, ) +from pydantic import TypeAdapter from mcp import MCPError from mcp.server import Server, ServerRequestContext @@ -518,3 +519,50 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21}) ) assert unstamped(second) == first + + +@requirement("tools:call:task-augmented") +async def test_task_augmented_call_tool_may_be_answered_with_a_create_task_result(connect: Connect) -> None: + """A 2025-11-25 server can hand back a task instead of running the tool inline. + + SEP-1686 makes `task` an opt-in on the request and `CreateTaskResult` the answer to it. The + SDK carries the vocabulary and validates both halves; the task store and the `tasks/*` + lifecycle handlers belong to the server author, so this drives the create step only. The + caller names the result type because `call_tool` resolves to the two core arms. + """ + task = types.Task( + task_id="task-1", + status="working", + created_at="2026-07-24T00:00:00Z", + last_updated_at="2026-07-24T00:00:00Z", + ttl=None, + ) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | types.CreateTaskResult: + assert params.name == "render" + if params.task is not None: + assert params.task.ttl == 60_000 + return types.CreateTaskResult(task=task) + return CallToolResult(content=[TextContent(text="rendered inline")]) + + server = Server("renderer", on_call_tool=call_tool) + result_type = TypeAdapter[CallToolResult | types.CreateTaskResult](CallToolResult | types.CreateTaskResult) + + async with connect(server) as client: + tasked = await client.session.send_request( + types.CallToolRequest( + params=types.CallToolRequestParams(name="render", task=types.TaskMetadata(ttl=60_000)) + ), + result_type, + ) + inline = await client.session.send_request( + types.CallToolRequest(params=types.CallToolRequestParams(name="render")), result_type + ) + + assert isinstance(tasked, types.CreateTaskResult) + # Whole-object equality: `ttl` is required and nullable, and a dump that drops it + # leaves a body the surface would reject, so the null has to survive the round trip. + assert tasked.task == task + assert isinstance(inline, CallToolResult) diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 959743e596..2d35827b12 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -259,7 +259,7 @@ ("resources/subscribe", "2025-11-25"): v2025.EmptyResult, ("resources/templates/list", "2025-11-25"): v2025.ListResourceTemplatesResult, ("resources/unsubscribe", "2025-11-25"): v2025.EmptyResult, - ("tools/call", "2025-11-25"): v2025.CallToolResult, + ("tools/call", "2025-11-25"): (v2025.CallToolResult, v2025.CreateTaskResult), ("tools/list", "2025-11-25"): v2025.ListToolsResult, ("completion/complete", "2026-07-28"): v2026.CompleteResult, ("prompts/get", "2026-07-28"): (v2026.GetPromptResult, v2026.InputRequiredResult), @@ -392,6 +392,7 @@ v2025.ListRootsResult: {"roots": [{"uri": "file:///workspace"}]}, v2025.ListToolsResult: {"tools": []}, v2025.ReadResourceResult: {"contents": []}, + v2025.AnyCallToolResult: {"content": []}, v2026.AnyCallToolResult: {"content": [], "resultType": "complete"}, v2026.AnyGetPromptResult: {"messages": [], "resultType": "complete"}, v2026.AnyReadResourceResult: {"contents": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"}, @@ -778,7 +779,9 @@ def test_input_required_unions_discriminate_identically_in_both_arm_orders(): ] for method, complete_body in complete_bodies.items(): row = methods.MONOLITH_RESULTS[method] - complete_arm, input_required_arm = get_args(row) + # tools/call carries a third arm (CreateTaskResult) that the other two do not; this + # test is about the complete-vs-input_required pair, which is always the first two. + complete_arm, input_required_arm = get_args(row)[:2] assert input_required_arm is types.InputRequiredResult bodies: list[dict[str, Any]] = [ complete_body, @@ -925,6 +928,25 @@ def test_serialize_server_result_preserves_arbitrary_meta_value_identically(vers assert sieved["_meta"] == meta +@pytest.mark.parametrize("ttl", [None, 60_000]) +def test_serialize_server_result_keeps_a_required_nullable_task_ttl(ttl: int | None): + """`Task.ttl` is required and nullable, so the sieve must emit it even when it is null. + + `exclude_none=True` would drop it, leaving a body that fails the very surface it + was sieved through; `KeepRequiredNullable` adds it back. + """ + task = { + "taskId": "t1", + "status": "working", + "createdAt": "2026-07-24T00:00:00Z", + "lastUpdatedAt": "2026-07-24T00:00:00Z", + "ttl": ttl, + } + sieved = methods.serialize_server_result("tools/call", "2025-11-25", {"task": task}) + assert sieved == {"task": task} + methods.validate_server_result("tools/call", "2025-11-25", sieved) + + def test_serialize_server_result_preserves_open_type_extras(): """`inputSchema` and nested `_meta` are open key-value bags; the sieve must not strip them.""" input_schema = {"type": "object", "title": "X", "additionalProperties": False, "$defs": {"Y": {"type": "string"}}} @@ -978,3 +1000,16 @@ def test_importing_the_module_builds_no_adapters_and_identical_rows_share_one(): # Identical row values at another version: no new adapters. fresh.parse_server_result("ping", "2024-11-05", {}) assert fresh._adapter.cache_info().currsize == 2 + + +def test_a_tools_call_body_carrying_both_arms_resolves_to_the_task(): + """A body with `content` and `task` is not a shape the spec defines, and the sieve keeps one. + + Pinning current behaviour: the 2025 arms have no `resultType` to discriminate on, so an + ambiguous body resolves to `CreateTaskResult` and the tool output goes. Before the task arm + existed the same body lost `task` instead, so this is which half is dropped, not whether. + A handler answers a task-augmented call with one or the other, never both. + """ + task = {"taskId": "t1", "status": "working", "createdAt": "x", "lastUpdatedAt": "y", "ttl": None} + both = {"content": [{"type": "text", "text": "hi"}], "task": task} + assert methods.serialize_server_result("tools/call", "2025-11-25", both) == {"task": task} diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py index 8c75205636..dd9dd734cc 100644 --- a/tests/types/test_parity.py +++ b/tests/types/test_parity.py @@ -7,9 +7,11 @@ import mcp_types as monolith import mcp_types._types as _types +import mcp_types.jsonrpc as jsonrpc import mcp_types.v2025_11_25 as v2025_11_25 import mcp_types.v2026_07_28 as v2026_07_28 import pytest +from mcp_types._wire_base import KeepRequiredNullable, admits_none from pydantic import BaseModel SURFACES: tuple[ModuleType, ...] = (v2025_11_25, v2026_07_28) @@ -209,6 +211,62 @@ def test_every_public_monolith_model_is_exported_from_mcp_types() -> None: assert not missing, f"_types models not in mcp_types.__all__: {sorted(missing)}" +def _models_with_a_required_nullable_field() -> list[tuple[str, type[BaseModel], frozenset[str]]]: + """Every model with a required field whose value may be `None`, and those fields' aliases. + + A bare `Any` counts: the schema declares those properties with no type at all, so null + is a legal value for them. Root models do not: `exclude_none` dumps their root value + directly rather than omitting a key, so they cannot lose it. + """ + found: list[tuple[str, type[BaseModel], frozenset[str]]] = [] + for module in (_types, jsonrpc, *SURFACES): + for name, obj in vars(module).items(): + if not name.isidentifier(): + continue # pydantic's `Request[...]` generic-alias entries; the concrete classes follow + if not (inspect.isclass(obj) and issubclass(obj, BaseModel)) or obj.__module__ != module.__name__: + continue + if getattr(obj, "__pydantic_root_model__", False): + continue + nullable_required = frozenset( + field.serialization_alias or field.alias or field_name + for field_name, field in obj.model_fields.items() + if field.is_required() and admits_none(field.annotation) + ) + if nullable_required: + found.append((f"{module.__name__}.{name}", obj, nullable_required)) + # An empty list would parametrize away to nothing and pass in silence. + assert found, "discovery found no required-nullable fields at all; the walk is broken" + return found + + +@pytest.mark.parametrize( + "qualname,cls,nullable_required", + _models_with_a_required_nullable_field(), + ids=lambda v: v if isinstance(v, str) else "", +) +def test_models_with_a_required_nullable_field_survive_an_exclude_none_dump( + qualname: str, cls: type[BaseModel], nullable_required: frozenset[str] +) -> None: + """`exclude_none=True` would drop such a field, leaving a body that fails its own schema. + + `KeepRequiredNullable` puts it back. This walk deliberately shares `admits_none` with the + runtime, so it is a consistency check rather than an oracle: what it catches is a model the + generator's independent schema-side rule (`_admits_null`) failed to give the base, or a + hand-written model nobody remembered. A spelling both rules miss is caught by neither, and + only an end-to-end test of that message would find it. + """ + assert issubclass(cls, KeepRequiredNullable), ( + f"{qualname} needs the KeepRequiredNullable base. For a generated model, regenerate the " + "surface packages; if that changes nothing, the schema spelling of this field is one " + "`_admits_null` in scripts/gen_surface_types.py does not recognise. Hand-written models " + "in _types.py and jsonrpc.py take the base directly." + ) + instance = cls.model_construct(**dict.fromkeys(cls.model_fields)) + dumped = instance.model_dump(by_alias=True, mode="json", exclude_none=True) + assert nullable_required <= dumped.keys() + assert all(dumped[alias] is None for alias in nullable_required) + + def test_every_surface_class_is_accounted_for() -> None: monolith_models = { name @@ -221,3 +279,26 @@ def test_every_surface_class_is_accounted_for() -> None: assert not unmapped, f"surface classes with no mapping: {sorted(unmapped)}" stale = (NAME_MAP.keys() | SKIP) - surface.keys() assert not stale, f"stale NAME_MAP/SKIP entries: {sorted(stale)}" + + +def test_keep_required_nullable_only_restores_what_exclude_none_removed() -> None: + """The base puts back a required null, and leaves every other dump mode alone. + + SDK-defined: `exclude`/`include` say the caller does not want the field at all, and a dump + without `exclude_none` never lost it, so neither case is the base's to fix. + """ + task = _types.Task(task_id="t1", status="working", created_at="x", last_updated_at="y", ttl=None) + + assert task.model_dump(by_alias=True, exclude_none=True)["ttl"] is None + assert "ttl" not in task.model_dump(by_alias=True, exclude_none=True, exclude={"ttl"}) + assert "ttl" not in task.model_dump(by_alias=True, exclude_none=True, exclude={"ttl": True}) + assert task.model_dump(by_alias=True, exclude_none=True, include={"task_id"}) == {"taskId": "t1"} + # A mapping entry that is not `True`/`...` selects within the field, so pydantic keeps the + # field itself and the null has to go back. `data` is `Any`, so descending into it is legal. + log = _types.LoggingMessageNotificationParams(level="info", data=None) + assert log.model_dump(exclude_none=True, exclude={"data": {"secret"}}) == {"level": "info", "data": None} + assert log.model_dump(exclude_none=True, exclude={"data": True}) == {"level": "info"} + # Without exclude_none nothing was dropped, so the wrap passes the dump through untouched. + assert task.model_dump(by_alias=True)["statusMessage"] is None + # The restored key follows the caller's alias choice rather than always using the wire spelling. + assert "ttl" in task.model_dump(exclude_none=True)