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
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
32 changes: 30 additions & 2 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,38 @@ 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; custom methods skip the version gate, so your `params_type` is their validation.

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
from typing import Literal

from mcp_types import Notification, NotificationParams


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

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 +224,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 @@ Two more. Neither declares anything.

`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), 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.

## 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 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.

### 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.** `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)**.
* **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
5 changes: 4 additions & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1334,7 +1334,10 @@ async def _on_notify(
# 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)
# A binding is the opt-in for methods core does not know; a no-op one silences the warning.
logger.warning(
"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
13 changes: 11 additions & 2 deletions src/mcp/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,19 @@ async def send_request(

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)
Comment thread
claude[bot] marked this conversation as resolved.
await channel.notify(data["method"], data.get("params"))
Expand Down
30 changes: 16 additions & 14 deletions tests/client/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,31 +867,33 @@ async def test_on_notify_drops_a_server_notification_absent_at_the_negotiated_ve
caplog: pytest.LogCaptureFixture,
):
"""`notifications/elicitation/complete` does not exist at 2025-06-18: it is
debug-log-dropped without reaching `message_handler`."""
warn-dropped without reaching `message_handler`."""
seen: list[object] = []
delivered = anyio.Event()

async def handler(msg: object) -> None:
seen.append(msg)
delivered.set()

with caplog.at_level("DEBUG", logger="client"):
async with raw_client_session(message_handler=handler) as (session, to_client, _):
_set_negotiated_version(session, "2025-06-18")
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/elicitation/complete", params={"elicitationId": "e1"}
)
async with raw_client_session(message_handler=handler) as (session, to_client, _):
_set_negotiated_version(session, "2025-06-18")
await to_client.send(
SessionMessage(
JSONRPCNotification(
jsonrpc="2.0", method="notifications/elicitation/complete", params={"elicitationId": "e1"}
)
)
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
)
await to_client.send(
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
)
await delivered.wait()
assert len(seen) == 1
assert isinstance(seen[0], types.ToolListChangedNotification)
assert "dropped 'notifications/elicitation/complete': not defined at 2025-06-18" in caplog.text
assert (
"dropped 'notifications/elicitation/complete': not defined at 2025-06-18 "
"and no notification binding is registered"
) in caplog.text


@pytest.mark.anyio
Expand Down
15 changes: 10 additions & 5 deletions tests/client/test_session_notification_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,9 @@ async def on_event(params: _EventParams) -> None:


@pytest.mark.anyio
async def test_unbound_vendor_notification_keeps_the_debug_drop(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: a vendor method with no binding keeps the debug-log-and-drop behaviour."""
caplog.set_level(logging.DEBUG, logger="client")

async def test_unbound_vendor_notification_is_dropped_with_a_warning(caplog: pytest.LogCaptureFixture) -> None:
"""SDK-defined: each vendor notification with no binding is dropped with a warning
naming the remedy; a binding for the method is what silences it."""
client_side, server_side = create_direct_dispatcher_pair()
binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler)
session = ClientSession(dispatcher=client_side, notification_bindings=[binding])
Expand All @@ -195,9 +194,15 @@ async def test_unbound_vendor_notification_keeps_the_debug_drop(caplog: pytest.L
async with session:
_adopt_modern(session)
await server_side.notify("notifications/vendor/unbound", {"seq": 1})
await server_side.notify("notifications/vendor/unbound", {"seq": 2})
server_side.close()

assert f"dropped 'notifications/vendor/unbound': not defined at {LATEST_MODERN_VERSION}" in caplog.text
expected = (
f"dropped 'notifications/vendor/unbound': not defined at {LATEST_MODERN_VERSION} "
"and no notification binding is registered"
)
levels = [r.levelno for r in caplog.records if r.getMessage() == expected]
assert levels == [logging.WARNING, logging.WARNING]


@pytest.mark.anyio
Expand Down
9 changes: 5 additions & 4 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -2535,10 +2535,11 @@ def __post_init__(self) -> None:
"against the binding's params type and delivered to its handler serially, in dispatch order."
),
added_in="2026-07-28",
deferred=(
"Covered at session tier by tests/client/test_session_notification_bindings.py: no public "
"server-side surface emits vendor-method notifications (ServerNotification is a closed union), "
"and HTTP-modern arrival additionally needs the subscriptions/listen client runtime."
note=(
"The server emits with related_request_id so the notification rides the originating "
"request's stream; a standalone vendor notification has no modern channel outside "
"subscriptions/listen. Queue bounds and shadowing edge cases stay pinned at session tier "
"in tests/client/test_session_notification_bindings.py."
),
),
# ═══════════════════════════════════════════════════════════════════════════
Expand Down
Loading