Skip to content

Commit cad5440

Browse files
committed
Make custom notifications first-class: typed send, observable drops
Widen ClientSession.send_notification and ServerSession.send_notification to accept any Notification subclass, mirroring send_request's open Request arm, so extensions can emit their own methods without casts. Raise dropped-notification logging from debug to a once-per-method warning that names the remedy: on the client when no NotificationBinding observes the method, on the server when a spec notification is not defined at the negotiated version or a custom method has no registered handler (an unserved spec notification stays at debug; repeat drops of a method log at debug so a stream cannot flood the log). Document the message_handler routing change and the binding channel in the migration guide, fix the callbacks page's catch-all claim, and cover the vendor-notification round trip with an interaction test, un-deferring its requirement.
1 parent 00a7014 commit cad5440

13 files changed

Lines changed: 247 additions & 51 deletions

File tree

docs/advanced/extensions.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,11 @@ def notifications(self) -> Sequence[NotificationBinding[Any]]:
214214
```
215215

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

219223
Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability
220224
ad follows them: on a legacy connection the claims dissolve and the identifier drops

docs/advanced/low-level-server.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,33 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
162162
--8<-- "docs_src/lowlevel/tutorial006.py"
163163
```
164164

165-
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
165+
* The first argument is the method string.
166166
* `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.
167167
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
168168

169+
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.
170+
171+
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:
172+
173+
```python
174+
class ReindexProgressParams(NotificationParams):
175+
percent: float
176+
177+
178+
class ReindexProgress(Notification[ReindexProgressParams, Literal["notifications/reindex/progress"]]):
179+
method: Literal["notifications/reindex/progress"] = "notifications/reindex/progress"
180+
params: ReindexProgressParams
181+
182+
183+
async def reindex(ctx: ServerRequestContext, params: ReindexParams) -> None:
184+
await ctx.session.send_notification(
185+
ReindexProgress(params=ReindexProgressParams(percent=40.0)),
186+
related_request_id=ctx.request_id,
187+
)
188+
```
189+
190+
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.
191+
169192
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.
170193

171194
One method you cannot claim:
@@ -196,7 +219,7 @@ Each of these is one idea you now have the vocabulary for; each has its own page
196219
* 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.
197220
* `_meta` on the result is addressed to the client application, not the model.
198221
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
199-
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
222+
* `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.
200223
* The capabilities a `Server` advertises are derived from which handlers you registered.
201224

202225
`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)**.

docs/client/callbacks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Two more. Neither declares anything.
135135

136136
`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.
137137

138-
`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.
138+
`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.
139139

140140
## Recap
141141

docs/migration.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1653,6 +1653,36 @@ Behavior changes:
16531653

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

1656+
### Vendor notifications never reach `message_handler`; observe them with a `NotificationBinding`
1657+
1658+
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.
1659+
1660+
Code that watched task status (or any vendor method) through `message_handler` registers a binding instead:
1661+
1662+
```python
1663+
from mcp.client import NotificationBinding
1664+
from mcp_types import TaskStatusNotificationParams
1665+
1666+
1667+
async def on_task_status(params: TaskStatusNotificationParams) -> None:
1668+
...
1669+
1670+
1671+
session = ClientSession(
1672+
read_stream,
1673+
write_stream,
1674+
notification_bindings=[
1675+
NotificationBinding(
1676+
method="notifications/tasks/status",
1677+
params_type=TaskStatusNotificationParams,
1678+
handler=on_task_status,
1679+
)
1680+
],
1681+
)
1682+
```
1683+
1684+
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.
1685+
16561686
### Experimental Tasks support removed
16571687

16581688
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`.

docs/whats-new.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are repla
201201
* **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.
202202
* **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)**.
203203
* **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)**.
204+
* **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)**.
204205
* **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.
205206
* **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.
206207
* **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)**.

src/mcp/client/session.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ def __init__(
363363
self._extensions = dict(extensions) if extensions is not None else None
364364
self._result_claims = _index_claims(result_claims, extensions)
365365
self._notification_bindings = _index_bindings(notification_bindings)
366+
self._warned_notification_drops: set[str] = set()
366367
self._active_claims: dict[str, ResultClaim[Any]] = {}
367368
self._call_tool_adapter = _CallToolResultAdapter
368369
self._binding_queues: dict[
@@ -527,9 +528,14 @@ async def send_request(
527528
return result_type.validate_python(raw, by_name=False)
528529
return result_type.model_validate(raw, by_name=False)
529530

530-
async def send_notification(self, notification: types.ClientNotification) -> None:
531+
async def send_notification(self, notification: types.ClientNotification | types.Notification[Any, Any]) -> None:
531532
"""Send a one-way notification. Usable before entering the context manager.
532533
534+
Spec notifications are the `types.ClientNotification` union members; any
535+
other `types.Notification` subclass is sent as-is, so extensions can emit
536+
their own methods (a python-sdk server routes those to handlers registered
537+
with `Server.add_notification_handler`).
538+
533539
Fire-and-forget: after the connection has closed, the notification is
534540
dropped with a debug log instead of raising.
535541
"""
@@ -1334,7 +1340,12 @@ async def _on_notify(
13341340
# Only methods unknown to the negotiated version's core tables reach the bindings.
13351341
binding = self._notification_bindings.get(method)
13361342
if binding is None:
1337-
logger.debug("dropped %r: not defined at %s", method, version)
1343+
# The first drop of a method warns; repeats log at debug so a stream cannot flood the log.
1344+
level = logging.WARNING if method not in self._warned_notification_drops else logging.DEBUG
1345+
self._warned_notification_drops.add(method)
1346+
logger.log(
1347+
level, "dropped %r: not defined at %s and no notification binding is registered", method, version
1348+
)
13381349
return
13391350
try:
13401351
bound_params = binding.params_type.model_validate(params or {})

src/mcp/server/runner.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import logging
1818
from collections.abc import AsyncIterator, Awaitable, Mapping
1919
from contextlib import asynccontextmanager
20-
from dataclasses import KW_ONLY, dataclass, replace
20+
from dataclasses import KW_ONLY, dataclass, field, replace
2121
from functools import cached_property, partial
2222
from typing import TYPE_CHECKING, Any, Generic, cast
2323

@@ -164,6 +164,14 @@ class ServerRunner(Generic[LifespanT]):
164164
init_options: InitializationOptions | None = None
165165
"""`InitializeResult` payload. Defaults to `server.create_initialization_options()`."""
166166

167+
_warned_notification_drops: set[str] = field(default_factory=lambda: set(), init=False, repr=False)
168+
169+
def _log_dropped_notification(self, method: str, message: str, *args: object) -> None:
170+
"""The first drop of a method warns; repeats log at debug so a stream cannot flood the log."""
171+
level = logging.WARNING if method not in self._warned_notification_drops else logging.DEBUG
172+
self._warned_notification_drops.add(method)
173+
logger.log(level, message, method, *args)
174+
167175
@cached_property
168176
def on_request(self) -> OnRequest:
169177
return self._on_request
@@ -251,11 +259,12 @@ async def _on_notify(
251259

252260
async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None:
253261
method, params = ctx.method, ctx.params
254-
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
262+
is_spec = method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS
263+
if is_spec:
255264
try:
256265
_methods.validate_client_notification(method, version, params)
257266
except KeyError:
258-
logger.debug("dropped %r: not defined at %s", method, version)
267+
self._log_dropped_notification(method, "dropped %r: not defined at %s", version)
259268
return
260269
except ValidationError:
261270
logger.warning("dropped %r: malformed params", method)
@@ -270,7 +279,13 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None:
270279
return
271280
entry = self.server.get_notification_handler(method)
272281
if entry is None:
273-
logger.debug("no handler for notification %s", method)
282+
if is_spec:
283+
# Not serving a spec notification is ordinary (most servers
284+
# ignore roots/list_changed); dropping a custom method the
285+
# peer went out of its way to send deserves a warning.
286+
logger.debug("no handler for notification %s", method)
287+
else:
288+
self._log_dropped_notification(method, "dropped %r: no notification handler is registered")
274289
return
275290
# Same absent-params contract as requests.
276291
try:

src/mcp/server/session.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,19 @@ async def send_request(
102102

103103
async def send_notification(
104104
self,
105-
notification: types.ServerNotification,
105+
notification: types.ServerNotification | types.Notification[Any, Any],
106106
related_request_id: types.RequestId | None = None,
107107
) -> None:
108-
"""Send a typed server-to-client notification."""
108+
"""Send a typed server-to-client notification.
109+
110+
Spec notifications are the `types.ServerNotification` union members; any
111+
other `types.Notification` subclass is sent as-is, so extensions can emit
112+
their own methods (a python-sdk client observes those by registering a
113+
`NotificationBinding`). The 2026-07-28 revision gives standalone
114+
notifications no channel outside `subscriptions/listen`, so on modern
115+
connections pass `related_request_id` to ride the originating request's
116+
stream.
117+
"""
109118
channel = self._request_outbound if related_request_id is not None else self._connection.outbound
110119
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
111120
await channel.notify(data["method"], data.get("params"))

0 commit comments

Comments
 (0)