Narrow message_handler's parameter to notifications and exceptions (#3168)

This commit is contained in:
Max 2026-07-26 00:24:48 +01:00 • committed by GitHub
commit 814072c94d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 74 additions and 147 deletions

View file

@ -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: 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.
## Recap

View file

@ -1688,7 +1688,9 @@ Behavior changes:
- **`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.
- **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)).
`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`.
- **`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.
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`.
### Experimental Tasks support removed

View file

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

View file

@ -3,7 +3,7 @@
import anyio
import mcp_types as types
from mcp.client import Client
from mcp.client import Client, IncomingMessage
from stories._harness import Target, run_client
@ -13,7 +13,7 @@ async def main(target: Target, *, mode: str = "auto") -> None:
received: list[types.ResourceListChangedNotification] = []
seen = anyio.Event()
async def on_message(message: object) -> None:
async def on_message(message: IncomingMessage) -> None:
if isinstance(message, types.ResourceListChangedNotification):
received.append(message)
seen.set()

View file

@ -4,7 +4,7 @@ import anyio
import mcp_types as types
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
from mcp.client import Client, ClientRequestContext
from mcp.client import Client, ClientRequestContext, IncomingMessage
from stories._harness import Target, run_client
@ -18,7 +18,7 @@ async def main(target: Target, *, mode: str = "auto") -> None:
return types.ElicitResult(action="cancel")
return types.ElicitResult(action="accept", content={"confirm": answer == "confirm"})
async def on_message(message: object) -> None:
async def on_message(message: IncomingMessage) -> None:
if isinstance(message, types.ResourceListChangedNotification):
list_changed.set()

View file

@ -20,7 +20,7 @@ from mcp.client.extension import (
UnexpectedClaimedResult,
advertise,
)
from mcp.client.session import ClientSession
from mcp.client.session import ClientSession, IncomingMessage
__all__ = [
"CacheConfig",
@ -32,6 +32,7 @@ __all__ = [
"ClientExtension",
"ClientRequestContext",
"ClientSession",
"IncomingMessage",
"InMemoryResponseCacheStore",
"InputRequiredRoundsExceededError",
"NotificationBinding",

View file

@ -9,11 +9,10 @@ import anyio
import mcp_types as types
from mcp.client._transport import ReadStream, WriteStream
from mcp.client.session import ClientSession
from mcp.client.session import ClientSession, IncomingMessage
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
if not sys.warnoptions:
warnings.simplefilter("ignore")
@ -22,9 +21,7 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("client")
async def message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception):
logger.error("Error: %s", message)
return

View file

@ -52,6 +52,7 @@ from mcp.client.session import (
ClientRequestContext,
ClientSession,
ElicitationFnT,
IncomingMessage,
ListRootsFnT,
LoggingFnT,
MessageHandlerFnT,
@ -68,7 +69,6 @@ from mcp.shared.dispatcher import Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp.shared.extension import validate_extension_identifier
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import event_to_notification
logger = logging.getLogger(__name__)
@ -155,9 +155,7 @@ def _strip_userinfo(url: str) -> str:
def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT:
"""Wrap the session message handler with cache eviction on server notifications."""
async def handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
async def handler(message: IncomingMessage) -> None:
if isinstance(message, types.ServerNotification):
try:
await cache.evict_for_notification(message)

View file

@ -6,7 +6,7 @@ from dataclasses import dataclass
from functools import reduce
from operator import or_
from types import TracebackType
from typing import Annotated, Any, Final, Literal, Protocol, cast, overload
from typing import Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload
import anyio
import anyio.abc
@ -53,7 +53,6 @@ from mcp.shared.inbound import (
)
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params
from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire
from mcp.shared.transport_context import TransportContext
@ -172,16 +171,20 @@ class LoggingFnT(Protocol):
async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch
IncomingMessage: TypeAlias = types.ServerNotification | Exception
"""What `message_handler` receives: the server notifications the session surfaces, plus transport-level exceptions.
`notifications/cancelled` is applied by the dispatcher and never surfaced, and a
`notifications/subscriptions/acknowledged` for a live `listen()` stream is consumed by that
stream, so neither reaches the handler.
"""
class MessageHandlerFnT(Protocol):
async def __call__(
self,
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None: ... # pragma: no branch
async def __call__(self, message: IncomingMessage) -> None: ... # pragma: no branch
async def _default_message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
async def _default_message_handler(message: IncomingMessage) -> None:
await anyio.lowlevel.checkpoint()
@ -331,8 +334,10 @@ class ClientSession:
`dispatcher=`), enter as an async context manager, then call
`initialize()`. The dispatcher owns the receive loop and request
correlation; this class owns the typed MCP layer and the constructor
callbacks. Transport `Exception` items reach `message_handler` only when
the session builds its own dispatcher from a stream pair.
callbacks. Transport `Exception` items reach `message_handler` on any
stream-backed dispatcher (`JSONRPCDispatcher`), whether built here from a
stream pair or supplied without a stream-exception hook of its own; an
in-process `DirectDispatcher` carries none.
Extension `result_claims` fold into tools/call parsing at `adopt()`;
`notification_bindings` observe vendor notifications via bounded FIFOs.

View file

@ -25,8 +25,8 @@ from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters
from mcp.client.streamable_http import streamable_http_client
from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared.dispatcher import ProgressFnT
from mcp.shared.exceptions import MCPError
from mcp.shared.session import ProgressFnT
class SseServerParameters(BaseModel):

View file

@ -1,22 +0,0 @@
"""Compatibility names that outlived the removed v1 session layer (`BaseSession`)."""
from typing import Generic, TypeVar
from mcp_types import RequestParamsMeta
from mcp.shared.dispatcher import ProgressFnT as ProgressFnT
from mcp.shared.message import MessageMetadata
RequestId = str | int
ReceiveRequestT = TypeVar("ReceiveRequestT")
SendResultT = TypeVar("SendResultT")
class RequestResponder(Generic[ReceiveRequestT, SendResultT]):
"""Typing stub for the v1 responder; the SDK never instantiates it."""
request_id: RequestId
request_meta: RequestParamsMeta | None
request: ReceiveRequestT
message_metadata: MessageMetadata

View file

@ -42,7 +42,7 @@ from mcp_types import (
)
from mcp_types.version import LATEST_MODERN_VERSION
from mcp.client import Client
from mcp.client import Client, IncomingMessage
from mcp.client._transport import TransportStreams
from mcp.client.caching import (
CacheConfig,
@ -57,13 +57,10 @@ from mcp.server.caching import CacheHint
from mcp.shared.exceptions import MCPError
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from tests.interaction._connect import BASE_URL, mounted_app
pytestmark = pytest.mark.anyio
IncomingMessage = RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception
def _coordinator(client: Client) -> ClientResponseCache:
cache = client._response_cache

View file

@ -1,6 +1,5 @@
from typing import Literal
import mcp_types as types
import pytest
from mcp_types import (
LoggingMessageNotificationParams,
@ -8,8 +7,8 @@ from mcp_types import (
)
from mcp import Client
from mcp.client import IncomingMessage
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.session import RequestResponder
class LoggingCollector:
@ -55,9 +54,7 @@ async def test_logging_callback():
return True
# Create a message handler to catch exceptions
async def message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
async def message_handler(message: IncomingMessage) -> None:
if isinstance(message, Exception): # pragma: no cover
raise message

View file

@ -16,8 +16,8 @@ from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from mcp import ClientSession, MCPError
from mcp.client import IncomingMessage
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.session import RequestResponder
pytestmark = pytest.mark.anyio
@ -82,9 +82,7 @@ async def test_non_compliant_notification_response() -> None:
"""
returned_exception = None
async def message_handler( # pragma: no cover
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
async def message_handler(message: IncomingMessage) -> None: # pragma: no cover
nonlocal returned_exception
if isinstance(message, Exception):
returned_exception = message

View file

@ -37,7 +37,7 @@ from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERS
from pydantic import FileUrl, ValidationError
from mcp import MCPError
from mcp.client import ClientRequestContext
from mcp.client import ClientRequestContext, IncomingMessage
from mcp.client.client import Client
from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession
from mcp.client.subscriptions import ToolsListChanged, listen
@ -45,7 +45,6 @@ from mcp.server import Server, ServerRequestContext
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY
from mcp.shared.transport_context import TransportContext
@ -123,9 +122,7 @@ async def test_client_session_initialize():
)
# Create a message handler to catch exceptions
async def message_handler( # pragma: no cover
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
async def message_handler(message: IncomingMessage) -> None: # pragma: no cover
if isinstance(message, Exception):
raise message
@ -1228,10 +1225,8 @@ async def test_raising_notification_callbacks_over_direct_dispatch_cost_only_tha
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
raise ValueError("logging callback boom")
async def message_handler(
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
assert not isinstance(message, RequestResponder | Exception)
async def message_handler(message: IncomingMessage) -> None:
assert not isinstance(message, Exception)
teed.append(message)
raise ValueError("message handler boom")

View file

@ -40,7 +40,7 @@ flows — with a single subprocess test for stdio.
```text
tests/interaction/
_requirements.py the requirements manifest (see below)
_helpers.py shared type aliases + the wire-recording transport
_helpers.py the wire-recording transport
_connect.py the transport-parametrized connection factories
conftest.py the connect fixture (the transport matrix)
test_coverage.py enforces the manifest ↔ test contract

View file

@ -1,28 +1,16 @@
"""Shared helpers for the interaction suite.
Keep this module small: it exists only for (a) types that every test would otherwise have to
assemble from the SDK's internals to annotate a client callback, and (b) the recording transport
used by the wire-level tests. Server fixtures and assertion helpers belong in the test that uses
them.
Keep this module small: it exists only for the recording transport used by the wire-level
tests. Server fixtures and assertion helpers belong in the test that uses them.
"""
from types import TracebackType
import anyio
from mcp_types import ClientResult, ServerNotification, ServerRequest
from typing_extensions import Self
from mcp.client._transport import ReadStream, Transport, TransportStreams, WriteStream
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
# TODO: this union is the parameter type of every client message handler (MessageHandlerFnT),
# but the SDK does not export a name for it -- writing a correctly-typed handler requires
# importing RequestResponder from mcp.shared.session and assembling the union by hand. It
# should be a named, exported alias next to MessageHandlerFnT (like ClientRequestContext is
# for the request callbacks), at which point this alias can be deleted.
IncomingMessage = RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
"""Everything a client message handler can receive."""
class _RecordingReadStream:

View file

@ -29,13 +29,12 @@ from mcp_types import (
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client import ClientRequestContext, ClientSession, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio

View file

@ -28,12 +28,11 @@ from mcp_types import (
)
from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client import ClientRequestContext, ClientSession, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio

View file

@ -29,12 +29,11 @@ from mcp_types import (
)
from mcp import MCPError, UrlElicitationRequiredError
from mcp.client import ClientRequestContext
from mcp.client import ClientRequestContext, IncomingMessage
from mcp.server import Server, ServerRequestContext
from mcp.server.session import ServerSession
from tests._stamp import Unstamp
from tests.interaction._connect import Connect
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio

Some files were not shown because too many files have changed in this diff Show more