Skip to content

Commit 814072c

Browse files
authored
Narrow message_handler's parameter to notifications and exceptions (#3168)
1 parent 47bfa85 commit 814072c

28 files changed

Lines changed: 74 additions & 147 deletions

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: every server notification the session surfaces reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. Two never do: `notifications/cancelled` is applied by the SDK rather than surfaced, and a subscription acknowledgment for a live `listen()` stream is consumed by that stream. Annotate the parameter with `IncomingMessage` (`ServerNotification | Exception`, exported from `mcp.client`). 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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1688,7 +1688,9 @@ Behavior changes:
16881688
- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected.
16891689
- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the `mcp.shared.context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession, Any]` become `ClientRequestContext` (details in [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)).
16901690

1691-
`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`.
1691+
- **`message_handler` no longer receives requests.** Server-initiated requests are answered by the typed callbacks (`sampling_callback`, `elicitation_callback`, `list_roots_callback`), so the handler's parameter is now `IncomingMessage = ServerNotification | Exception`, exported from `mcp.client`. Replace the hand-written v1 union `RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception` with `IncomingMessage`; `RequestResponder` is gone (below), so the old annotation no longer imports.
1692+
1693+
The `mcp.shared.session` module is gone. `RequestResponder` is removed — `respond()`, the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) and `BaseSession._in_flight` have no replacement; inbound cancellation is handled by `JSONRPCDispatcher`. `ProgressFnT` now lives only in `mcp.shared.dispatcher`, and `RequestId` in `mcp_types`.
16921694

16931695
### Experimental Tasks support removed
16941696

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ Each of these is a section in the **[Migration Guide](migration.md)**:
146146

147147
* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification.
148148
* 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.
149-
* `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths.
149+
* `mcp.types`, `mcp.shared.version`, `mcp.shared.progress`, and `mcp.shared.session` (with the `RequestResponder` stub v1 `message_handler` annotations imported) as import paths.
150150
* The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams).
151151
* `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor.
152152
* `MCPServer.get_context()`, `mount_path=`, and the lowlevel `Server`'s decorator methods, ContextVar, and handler dicts.

examples/stories/standalone_get/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import anyio
44
import mcp_types as types
55

6-
from mcp.client import Client
6+
from mcp.client import Client, IncomingMessage
77
from stories._harness import Target, run_client
88

99

@@ -13,7 +13,7 @@ async def main(target: Target, *, mode: str = "auto") -> None:
1313
received: list[types.ResourceListChangedNotification] = []
1414
seen = anyio.Event()
1515

16-
async def on_message(message: object) -> None:
16+
async def on_message(message: IncomingMessage) -> None:
1717
if isinstance(message, types.ResourceListChangedNotification):
1818
received.append(message)
1919
seen.set()

examples/stories/stickynotes/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import mcp_types as types
55
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
66

7-
from mcp.client import Client, ClientRequestContext
7+
from mcp.client import Client, ClientRequestContext, IncomingMessage
88
from stories._harness import Target, run_client
99

1010

@@ -18,7 +18,7 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa
1818
return types.ElicitResult(action="cancel")
1919
return types.ElicitResult(action="accept", content={"confirm": answer == "confirm"})
2020

21-
async def on_message(message: object) -> None:
21+
async def on_message(message: IncomingMessage) -> None:
2222
if isinstance(message, types.ResourceListChangedNotification):
2323
list_changed.set()
2424

src/mcp/client/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
UnexpectedClaimedResult,
2121
advertise,
2222
)
23-
from mcp.client.session import ClientSession
23+
from mcp.client.session import ClientSession, IncomingMessage
2424

2525
__all__ = [
2626
"CacheConfig",
@@ -32,6 +32,7 @@
3232
"ClientExtension",
3333
"ClientRequestContext",
3434
"ClientSession",
35+
"IncomingMessage",
3536
"InMemoryResponseCacheStore",
3637
"InputRequiredRoundsExceededError",
3738
"NotificationBinding",

src/mcp/client/__main__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@
99
import mcp_types as types
1010

1111
from mcp.client._transport import ReadStream, WriteStream
12-
from mcp.client.session import ClientSession
12+
from mcp.client.session import ClientSession, IncomingMessage
1313
from mcp.client.sse import sse_client
1414
from mcp.client.stdio import StdioServerParameters, stdio_client
1515
from mcp.shared.message import SessionMessage
16-
from mcp.shared.session import RequestResponder
1716

1817
if not sys.warnoptions:
1918
warnings.simplefilter("ignore")
@@ -22,9 +21,7 @@
2221
logger = logging.getLogger("client")
2322

2423

25-
async def message_handler(
26-
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
27-
) -> None:
24+
async def message_handler(message: IncomingMessage) -> None:
2825
if isinstance(message, Exception):
2926
logger.error("Error: %s", message)
3027
return

src/mcp/client/client.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
ClientRequestContext,
5353
ClientSession,
5454
ElicitationFnT,
55+
IncomingMessage,
5556
ListRootsFnT,
5657
LoggingFnT,
5758
MessageHandlerFnT,
@@ -68,7 +69,6 @@
6869
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
6970
from mcp.shared.extension import validate_extension_identifier
7071
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
71-
from mcp.shared.session import RequestResponder
7272
from mcp.shared.subscriptions import event_to_notification
7373

7474
logger = logging.getLogger(__name__)
@@ -155,9 +155,7 @@ def _strip_userinfo(url: str) -> str:
155155
def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT:
156156
"""Wrap the session message handler with cache eviction on server notifications."""
157157

158-
async def handler(
159-
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
160-
) -> None:
158+
async def handler(message: IncomingMessage) -> None:
161159
if isinstance(message, types.ServerNotification):
162160
try:
163161
await cache.evict_for_notification(message)

src/mcp/client/session.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from functools import reduce
77
from operator import or_
88
from types import TracebackType
9-
from typing import Annotated, Any, Final, Literal, Protocol, cast, overload
9+
from typing import Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload
1010

1111
import anyio
1212
import anyio.abc
@@ -53,7 +53,6 @@
5353
)
5454
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params
5555
from mcp.shared.message import ClientMessageMetadata, SessionMessage
56-
from mcp.shared.session import RequestResponder
5756
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire
5857
from mcp.shared.transport_context import TransportContext
5958

@@ -172,16 +171,20 @@ class LoggingFnT(Protocol):
172171
async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch
173172

174173

174+
IncomingMessage: TypeAlias = types.ServerNotification | Exception
175+
"""What `message_handler` receives: the server notifications the session surfaces, plus transport-level exceptions.
176+
177+
`notifications/cancelled` is applied by the dispatcher and never surfaced, and a
178+
`notifications/subscriptions/acknowledged` for a live `listen()` stream is consumed by that
179+
stream, so neither reaches the handler.
180+
"""
181+
182+
175183
class MessageHandlerFnT(Protocol):
176-
async def __call__(
177-
self,
178-
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
179-
) -> None: ... # pragma: no branch
184+
async def __call__(self, message: IncomingMessage) -> None: ... # pragma: no branch
180185

181186

182-
async def _default_message_handler(
183-
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
184-
) -> None:
187+
async def _default_message_handler(message: IncomingMessage) -> None:
185188
await anyio.lowlevel.checkpoint()
186189

187190

@@ -331,8 +334,10 @@ class ClientSession:
331334
`dispatcher=`), enter as an async context manager, then call
332335
`initialize()`. The dispatcher owns the receive loop and request
333336
correlation; this class owns the typed MCP layer and the constructor
334-
callbacks. Transport `Exception` items reach `message_handler` only when
335-
the session builds its own dispatcher from a stream pair.
337+
callbacks. Transport `Exception` items reach `message_handler` on any
338+
stream-backed dispatcher (`JSONRPCDispatcher`), whether built here from a
339+
stream pair or supplied without a stream-exception hook of its own; an
340+
in-process `DirectDispatcher` carries none.
336341
337342
Extension `result_claims` fold into tools/call parsing at `adopt()`;
338343
`notification_bindings` observe vendor notifications via bounded FIFOs.

src/mcp/client/session_group.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
from mcp.client.stdio import StdioServerParameters
2626
from mcp.client.streamable_http import streamable_http_client
2727
from mcp.shared._httpx_utils import create_mcp_http_client
28+
from mcp.shared.dispatcher import ProgressFnT
2829
from mcp.shared.exceptions import MCPError
29-
from mcp.shared.session import ProgressFnT
3030

3131

3232
class SseServerParameters(BaseModel):

0 commit comments

Comments
 (0)