Skip to content

Commit 0846708

Browse files
committed
Warn per event on unbound client notifications; keep server drops at debug
Drop the once-per-method dedup and its per-runner state: the client now warns on every unbound notification (registering a binding, even a no-op one, is the silence), and server-side inbound drops return to debug since the peer is an arbitrary client and warning on its input floods the log. Revert the ClientSession.send_notification widening: the 2026-07-28 transports do not agree on client-to-server custom notifications yet, so the closed union stays the accurate type. Carve notifications/cancelled and listen-owned acks out of the message_handler catch-all claim, add the imports the notification example was missing, and note that a json_response answer has no stream to carry request-scoped notifications.
1 parent cad5440 commit 0846708

9 files changed

Lines changed: 36 additions & 92 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,16 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
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.
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; custom methods skip the version gate, so your `params_type` is their validation.
170170

171171
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:
172172

173173
```python
174+
from typing import Literal
175+
176+
from mcp_types import Notification, NotificationParams
177+
178+
174179
class ReindexProgressParams(NotificationParams):
175180
percent: float
176181

@@ -187,7 +192,7 @@ async def reindex(ctx: ServerRequestContext, params: ReindexParams) -> None:
187192
)
188193
```
189194

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.
195+
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`, and a streamable HTTP response answered with `json_response=True` has no stream, so notifications sent during it are dropped. A python-sdk client observes vendor methods with a `NotificationBinding` (**[Extensions](extensions.md)**) and warns on each one it has no binding for.
191196

192197
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.
193198

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 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.
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), except `notifications/cancelled`, which the session applies internally, and acknowledgements consumed by an open `listen()` stream. On a stream-backed transport it also receives 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1681,7 +1681,7 @@ session = ClientSession(
16811681
)
16821682
```
16831683

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.
1684+
On the high-level `Client`, bindings are declared by a `ClientExtension`'s `notifications()`; see [Extensions](advanced/extensions.md). The server's sending direction also loses its `cast`: `ServerSession.send_notification` accepts any `mcp_types.Notification` subclass, where v1 typed it to the closed union.
16851685

16861686
### Experimental Tasks support removed
16871687

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +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)**.
204+
* **Vendor notifications are typed end to end.** `ServerSession.send_notification` 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)**.
205205
* **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.
206206
* **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.
207207
* **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: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,6 @@ 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()
367366
self._active_claims: dict[str, ResultClaim[Any]] = {}
368367
self._call_tool_adapter = _CallToolResultAdapter
369368
self._binding_queues: dict[
@@ -528,14 +527,9 @@ async def send_request(
528527
return result_type.validate_python(raw, by_name=False)
529528
return result_type.model_validate(raw, by_name=False)
530529

531-
async def send_notification(self, notification: types.ClientNotification | types.Notification[Any, Any]) -> None:
530+
async def send_notification(self, notification: types.ClientNotification) -> None:
532531
"""Send a one-way notification. Usable before entering the context manager.
533532
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-
539533
Fire-and-forget: after the connection has closed, the notification is
540534
dropped with a debug log instead of raising.
541535
"""
@@ -1340,11 +1334,9 @@ async def _on_notify(
13401334
# Only methods unknown to the negotiated version's core tables reach the bindings.
13411335
binding = self._notification_bindings.get(method)
13421336
if binding is None:
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
1337+
# A binding is the opt-in for methods core does not know; a no-op one silences the warning.
1338+
logger.warning(
1339+
"dropped %r: not defined at %s and no notification binding is registered", method, version
13481340
)
13491341
return
13501342
try:

src/mcp/server/runner.py

Lines changed: 4 additions & 19 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, field, replace
20+
from dataclasses import KW_ONLY, dataclass, replace
2121
from functools import cached_property, partial
2222
from typing import TYPE_CHECKING, Any, Generic, cast
2323

@@ -164,14 +164,6 @@ 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-
175167
@cached_property
176168
def on_request(self) -> OnRequest:
177169
return self._on_request
@@ -259,12 +251,11 @@ async def _on_notify(
259251

260252
async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None:
261253
method, params = ctx.method, ctx.params
262-
is_spec = method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS
263-
if is_spec:
254+
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
264255
try:
265256
_methods.validate_client_notification(method, version, params)
266257
except KeyError:
267-
self._log_dropped_notification(method, "dropped %r: not defined at %s", version)
258+
logger.debug("dropped %r: not defined at %s", method, version)
268259
return
269260
except ValidationError:
270261
logger.warning("dropped %r: malformed params", method)
@@ -279,13 +270,7 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None:
279270
return
280271
entry = self.server.get_notification_handler(method)
281272
if entry is None:
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")
273+
logger.debug("no handler for notification %s", method)
289274
return
290275
# Same absent-params contract as requests.
291276
try:

tests/client/test_session.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import AsyncIterator, Mapping
44
from contextlib import AsyncExitStack, asynccontextmanager
5-
from typing import Any, Literal, cast
5+
from typing import Any, cast
66

77
import anyio
88
import anyio.abc
@@ -1475,29 +1475,6 @@ async def test_send_notification_after_close_is_dropped_silently():
14751475
s.close()
14761476

14771477

1478-
class _ReceiptEventParams(types.NotificationParams):
1479-
seq: int
1480-
1481-
1482-
class _ReceiptEvent(types.Notification[_ReceiptEventParams, Literal["notifications/com.example/receipt"]]):
1483-
method: Literal["notifications/com.example/receipt"] = "notifications/com.example/receipt"
1484-
params: _ReceiptEventParams
1485-
1486-
1487-
@pytest.mark.anyio
1488-
async def test_send_notification_accepts_a_custom_notification_model():
1489-
"""A `types.Notification` subclass outside the spec union serializes onto the wire
1490-
as-is, so extensions can emit their own methods without casts."""
1491-
async with raw_client_session() as (session, _, recv_from_client):
1492-
await session.send_notification(_ReceiptEvent(params=_ReceiptEventParams(seq=7)))
1493-
with anyio.fail_after(5):
1494-
sent = await recv_from_client.receive()
1495-
notification = sent.message
1496-
assert isinstance(notification, JSONRPCNotification)
1497-
assert notification.method == "notifications/com.example/receipt"
1498-
assert notification.params == {"seq": 7}
1499-
1500-
15011478
# --- discover() ladder ---
15021479

15031480

tests/client/test_session_notification_bindings.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,12 @@ async def on_event(params: _EventParams) -> None:
183183

184184
@pytest.mark.anyio
185185
async def test_unbound_vendor_notification_is_dropped_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
186-
"""SDK-defined: a vendor method with no binding is dropped with a warning naming the
187-
remedy; repeats of the same method drop to debug so a stream cannot flood the log."""
186+
"""SDK-defined: each vendor notification with no binding is dropped with a warning
187+
naming the remedy; a binding for the method is what silences it."""
188188
client_side, server_side = create_direct_dispatcher_pair()
189189
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
190190
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
191-
with caplog.at_level(logging.DEBUG, logger="client"), anyio.fail_after(5):
191+
with anyio.fail_after(5):
192192
async with anyio.create_task_group() as tg:
193193
await tg.start(server_side.run, _server_on_request, _server_on_notify)
194194
async with session:
@@ -202,7 +202,7 @@ async def test_unbound_vendor_notification_is_dropped_with_a_warning(caplog: pyt
202202
"and no notification binding is registered"
203203
)
204204
levels = [r.levelno for r in caplog.records if r.getMessage() == expected]
205-
assert levels == [logging.WARNING, logging.DEBUG]
205+
assert levels == [logging.WARNING, logging.WARNING]
206206

207207

208208
@pytest.mark.anyio

0 commit comments

Comments
 (0)