Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 52 additions & 4 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The pasted subclass advertises tasks to every handshake client, not just 2025-11-25. create_initialization_options() receives no negotiated protocol version, and the runner returns its capabilities unchanged after negotiating older handshake revisions; those revisions cannot accept the advertised task-augmented tools/call result. Please qualify this recipe as requiring a 2025-11-25-only endpoint/negotiation policy (or document a version-aware response layer) rather than presenting it as a safe advertisement for a server that also serves older handshake clients.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 1689:

<comment>The pasted subclass advertises `tasks` to every handshake client, not just 2025-11-25. `create_initialization_options()` receives no negotiated protocol version, and the runner returns its capabilities unchanged after negotiating older handshake revisions; those revisions cannot accept the advertised task-augmented `tools/call` result. Please qualify this recipe as requiring a 2025-11-25-only endpoint/negotiation policy (or document a version-aware response layer) rather than presenting it as a safe advertisement for a server that also serves older handshake clients.</comment>

<file context>
@@ -1680,23 +1680,20 @@ Two things to know about handlers registered this way. They serve every negotiat
+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(),
</file context>

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

Expand Down
2 changes: 1 addition & 1 deletion docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions scripts/gen_surface_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<bases>[^)]+)\):$",
rf"class {name}(\g<bases>, 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"]
Expand All @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions src/mcp-types/mcp_types/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading