Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion docs/advanced/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,11 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]:
```

The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto
or reply.
or reply. The emitting half needs no registration: the server side of your extension defines the
notification as a `mcp_types.Notification` subclass and sends it with
`ctx.session.send_notification(..., related_request_id=ctx.request_id)`, as shown in
**[The low-level Server](low-level-server.md#a-method-of-your-own)**. A client without the binding
drops the notification with a warning.
Comment thread
maxisbey marked this conversation as resolved.

Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability
ad follows them: on a legacy connection the claims dissolve and the identifier drops
Expand Down
27 changes: 25 additions & 2 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,33 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
--8<-- "docs_src/lowlevel/tutorial006.py"
```

* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
* The first argument is the method string.
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.

Notifications have a twin in each direction. Inbound, `add_notification_handler(method, params_type, handler)` registers `async (ctx, params) -> None`. Spec-defined notification methods are validated against the negotiated version's tables before dispatch, and one that does not exist at that version is dropped with a warning. Custom methods skip the version gate; your `params_type` is their validation, and a custom notification nobody registered a handler for is dropped with a warning.

Outbound, `ctx.session.send_notification(...)` accepts any `mcp_types.Notification` subclass, not just the spec-defined union, so the notifying half of a vendor protocol is one model away:

```python
class ReindexProgressParams(NotificationParams):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
percent: float


class ReindexProgress(Notification[ReindexProgressParams, Literal["notifications/reindex/progress"]]):
method: Literal["notifications/reindex/progress"] = "notifications/reindex/progress"
params: ReindexProgressParams


async def reindex(ctx: ServerRequestContext, params: ReindexParams) -> None:
await ctx.session.send_notification(
ReindexProgress(params=ReindexProgressParams(percent=40.0)),
related_request_id=ctx.request_id,
)
```

Pass `related_request_id` so the notification rides the originating request's stream; the 2026-07-28 wire gives standalone notifications no channel outside `subscriptions/listen`. A python-sdk client observes vendor methods with a `NotificationBinding` (**[Extensions](extensions.md)**) and warns about ones it has no binding for.

One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC.

One method you cannot claim:
Expand Down Expand Up @@ -196,7 +219,7 @@ Each of these is one idea you now have the vocabulary for; each has its own page
* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return.
* `_meta` on the result is addressed to the client application, not the model.
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved. `add_notification_handler` and `ctx.session.send_notification` are the notification twins.
* The capabilities a `Server` advertises are derived from which handlers you registered.

`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.
2 changes: 1 addition & 1 deletion docs/client/callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@

`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.

`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
`message_handler` is the catch-all for spec traffic: every notification the negotiated protocol version defines reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. Vendor and extension methods are outside that set; they are delivered only to a matching `NotificationBinding` (**[Extensions](../advanced/extensions.md)**), and without one they are dropped with a warning. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.

Check warning on line 138 in docs/client/callbacks.md

View check run for this annotation

Claude / Claude Code Review

callbacks.md overclaims: notifications/cancelled (and owned subscription acks) never reach message_handler

The rewritten sentence overclaims: "every notification the negotiated protocol version defines reaches it" is contradicted by the SDK itself — `notifications/cancelled` (defined at every negotiated version) is deliberately swallowed by `ClientSession._on_notify` before the `message_handler` tee, and a well-formed `notifications/subscriptions/acknowledged` owned by a live `listen()` route is consumed as driver state and never tees either. Since this PR re-scoped the sentence to be precise about `
Comment thread
maxisbey marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

## Recap

Expand Down
30 changes: 30 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,36 @@ 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`.

### Vendor notifications never reach `message_handler`; observe them with a `NotificationBinding`

In v1 every inbound server notification was validated against the closed `ServerNotification` union. Methods outside the union were dropped with a warning before any callback ran, and `notifications/tasks/status` was a union member, so it did reach `message_handler`. In v2 the notification tables are per protocol version and `TaskStatusNotification` is types-only: a method the negotiated version does not define is delivered only to a matching `NotificationBinding`, and without one it is dropped with a warning naming the method. `message_handler` keeps its typed contract and never sees these.

Code that watched task status (or any vendor method) through `message_handler` registers a binding instead:

```python
from mcp.client import NotificationBinding
from mcp_types import TaskStatusNotificationParams


async def on_task_status(params: TaskStatusNotificationParams) -> None:
...


session = ClientSession(
read_stream,
write_stream,
notification_bindings=[
NotificationBinding(
method="notifications/tasks/status",
params_type=TaskStatusNotificationParams,
handler=on_task_status,
)
],
)
```

On the high-level `Client`, bindings are declared by a `ClientExtension`'s `notifications()`; see [Extensions](advanced/extensions.md). The sending direction also loses its `cast`: `send_notification` on both `ClientSession` and `ServerSession` accepts any `mcp_types.Notification` subclass, where v1 typed it to the closed unions.

### Experimental Tasks support 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`.
Expand Down
1 change: 1 addition & 0 deletions docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules.
* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**.
* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**.
* **Vendor notifications are typed end to end.** `send_notification` on both sessions accepts any `mcp_types.Notification` subclass, and a client observes methods outside the negotiated version's tables with a `NotificationBinding` (on `ClientSession` directly, or declared by a `ClientExtension`); a notification nothing observes is dropped with a warning, not silently. **[Extensions](advanced/extensions.md)**.
* **Error codes got standardized.** A missing resource is `-32602` with the URI in `error.data`, and the new spec-reserved codes appear as `-32020` (header mismatch), `-32021` (missing required capability), and `-32022` (unsupported protocol version). **[Troubleshooting](troubleshooting.md)** is keyed by the exact messages.
* **Authorization got harder to hold wrong.** The client validates the `iss` returned with the authorization code ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); your `callback_handler` now returns an `AuthorizationCodeResult`), sends `application_type` when it registers, and never replays credentials against a different authorization server. New in the enterprise corner: the [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow. The **[Migration Guide](migration.md)** lists every OAuth change; **[OAuth for clients](client/oauth-clients.md)** and **[Identity assertion](client/identity-assertion.md)** are the pages.
* **Every server is traceable.** OpenTelemetry ships on by default as middleware: every request gets a server span, at no cost until the process configures an exporter. When both ends run the SDK, the client also propagates W3C trace context in `_meta`, so the traces join up. **[OpenTelemetry](run/opentelemetry.md)**.
Expand Down
15 changes: 13 additions & 2 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
self._extensions = dict(extensions) if extensions is not None else None
self._result_claims = _index_claims(result_claims, extensions)
self._notification_bindings = _index_bindings(notification_bindings)
self._warned_notification_drops: set[str] = set()
self._active_claims: dict[str, ResultClaim[Any]] = {}
self._call_tool_adapter = _CallToolResultAdapter
self._binding_queues: dict[
Expand Down Expand Up @@ -527,9 +528,14 @@
return result_type.validate_python(raw, by_name=False)
return result_type.model_validate(raw, by_name=False)

async def send_notification(self, notification: types.ClientNotification) -> None:
async def send_notification(self, notification: types.ClientNotification | types.Notification[Any, Any]) -> None:
"""Send a one-way notification. Usable before entering the context manager.

Spec notifications are the `types.ClientNotification` union members; any
other `types.Notification` subclass is sent as-is, so extensions can emit
their own methods (a python-sdk server routes those to handlers registered
with `Server.add_notification_handler`).

Check failure on line 537 in src/mcp/client/session.py

View check run for this annotation

Claude / Claude Code Review

Custom client->server notifications are silently discarded on the default Client(server) modern in-process path

Custom client->server notifications sent via the newly-widened `ClientSession.send_notification` are silently discarded on the default `Client(server)` in-process (modern) path: the server side of the dispatcher pair uses the no-op `_no_inbound_client_notifications` (src/mcp/client/client.py:117), so a handler registered with `Server.add_notification_handler` never fires and no log line is emitted at any level — contradicting this PR's new docstring and the low-level-server.md "dropped with a wa
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

Fire-and-forget: after the connection has closed, the notification is
dropped with a debug log instead of raising.
"""
Expand Down Expand Up @@ -1334,7 +1340,12 @@
# Only methods unknown to the negotiated version's core tables reach the bindings.
Comment thread
maxisbey marked this conversation as resolved.
binding = self._notification_bindings.get(method)
if binding is None:
logger.debug("dropped %r: not defined at %s", method, version)
# The first drop of a method warns; repeats log at debug so a stream cannot flood the log.
level = logging.WARNING if method not in self._warned_notification_drops else logging.DEBUG
self._warned_notification_drops.add(method)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
logger.log(
level, "dropped %r: not defined at %s and no notification binding is registered", method, version
)
return
try:
bound_params = binding.params_type.model_validate(params or {})
Expand Down
23 changes: 19 additions & 4 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import logging
from collections.abc import AsyncIterator, Awaitable, Mapping
from contextlib import asynccontextmanager
from dataclasses import KW_ONLY, dataclass, replace
from dataclasses import KW_ONLY, dataclass, field, replace
from functools import cached_property, partial
from typing import TYPE_CHECKING, Any, Generic, cast

Expand Down Expand Up @@ -164,6 +164,14 @@
init_options: InitializationOptions | None = None
"""`InitializeResult` payload. Defaults to `server.create_initialization_options()`."""

_warned_notification_drops: set[str] = field(default_factory=lambda: set(), init=False, repr=False)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

def _log_dropped_notification(self, method: str, message: str, *args: object) -> None:
"""The first drop of a method warns; repeats log at debug so a stream cannot flood the log."""
level = logging.WARNING if method not in self._warned_notification_drops else logging.DEBUG
self._warned_notification_drops.add(method)
logger.log(level, message, method, *args)

Check failure on line 173 in src/mcp/server/runner.py

View check run for this annotation

Claude / Claude Code Review

Warn-once dedup for dropped notifications is ineffective on the modern stream path (fresh ServerRunner per notification)

The once-per-method warn dedup for dropped notifications never engages on the modern (2026-07-28) stream path: `_serve_modern_stream.on_notify` builds a fresh `ServerRunner` (and `Connection.from_envelope`) for every inbound notification, so `_warned_notification_drops` is always empty and every repeat of the same unhandled custom method (or version-gated spec method) logs at WARNING — exactly the log flood this mechanism was added to prevent, and noisier than the pre-PR debug-only behavior on t
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

@cached_property
def on_request(self) -> OnRequest:
return self._on_request
Expand Down Expand Up @@ -251,11 +259,12 @@

async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None:
method, params = ctx.method, ctx.params
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
is_spec = method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS
if is_spec:
try:
_methods.validate_client_notification(method, version, params)
except KeyError:
logger.debug("dropped %r: not defined at %s", method, version)
self._log_dropped_notification(method, "dropped %r: not defined at %s", version)
return
except ValidationError:
logger.warning("dropped %r: malformed params", method)
Expand All @@ -270,7 +279,13 @@
return
entry = self.server.get_notification_handler(method)
if entry is None:
logger.debug("no handler for notification %s", method)
if is_spec:
# Not serving a spec notification is ordinary (most servers
# ignore roots/list_changed); dropping a custom method the
# peer went out of its way to send deserves a warning.
logger.debug("no handler for notification %s", method)
else:
self._log_dropped_notification(method, "dropped %r: no notification handler is registered")
return
# Same absent-params contract as requests.
try:
Expand Down
13 changes: 11 additions & 2 deletions src/mcp/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,23 @@
pass
return result_type.model_validate(result, by_name=False)

async def send_notification(
self,
notification: types.ServerNotification,
notification: types.ServerNotification | types.Notification[Any, Any],
related_request_id: types.RequestId | None = None,
) -> None:
"""Send a typed server-to-client notification."""
"""Send a typed server-to-client notification.

Spec notifications are the `types.ServerNotification` union members; any
other `types.Notification` subclass is sent as-is, so extensions can emit
their own methods (a python-sdk client observes those by registering a
`NotificationBinding`). The 2026-07-28 revision gives standalone
notifications no channel outside `subscriptions/listen`, so on modern
connections pass `related_request_id` to ride the originating request's
stream.
"""
channel = self._request_outbound if related_request_id is not None else self._connection.outbound
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)

Check warning on line 119 in src/mcp/server/session.py

View check run for this annotation

Claude / Claude Code Review

Server-emitted vendor notifications drop with no log on modern HTTP with json_response=True (and debug-only without related_request_id)

The server-to-client vendor-notification flow this PR documents and type-legalizes can still drop silently on the modern streamable-HTTP entry: with `json_response=True`, `handle_modern_request` never sets `dctx.sink`, so `ctx.session.send_notification(..., related_request_id=ctx.request_id)` — the exact pattern the new docs prescribe — is discarded with no log at any level; and without `related_request_id`, `_NoChannelOutbound.notify` drops at debug only, while this PR upgraded the receive-side
Comment thread
claude[bot] marked this conversation as resolved.
await channel.notify(data["method"], data.get("params"))

def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
Expand Down
Loading
Loading