Skip to content
Merged
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
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: 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

Expand Down
4 changes: 3 additions & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1651,7 +1651,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`.
Comment thread
maxisbey marked this conversation as resolved.

### Experimental Tasks support removed

Expand Down
2 changes: 1 addition & 1 deletion docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions examples/stories/standalone_get/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions examples/stories/stickynotes/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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


Expand All @@ -18,7 +18,7 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa
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()

Expand Down
3 changes: 2 additions & 1 deletion src/mcp/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
UnexpectedClaimedResult,
advertise,
)
from mcp.client.session import ClientSession
from mcp.client.session import ClientSession, IncomingMessage

__all__ = [
"CacheConfig",
Expand All @@ -32,6 +32,7 @@
"ClientExtension",
"ClientRequestContext",
"ClientSession",
"IncomingMessage",
"InMemoryResponseCacheStore",
"InputRequiredRoundsExceededError",
"NotificationBinding",
Expand Down
7 changes: 2 additions & 5 deletions src/mcp/client/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
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")
Expand All @@ -22,9 +21,7 @@
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
Expand Down
6 changes: 2 additions & 4 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
ClientRequestContext,
ClientSession,
ElicitationFnT,
IncomingMessage,
ListRootsFnT,
LoggingFnT,
MessageHandlerFnT,
Expand All @@ -68,7 +69,6 @@
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__)
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 16 additions & 11 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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
Expand Down Expand Up @@ -53,7 +53,6 @@
)
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

Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/client/session_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
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):
Expand Down
22 changes: 0 additions & 22 deletions src/mcp/shared/session.py

This file was deleted.

5 changes: 1 addition & 4 deletions tests/client/test_client_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
)
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,
Expand All @@ -57,13 +57,10 @@
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
Expand Down
7 changes: 2 additions & 5 deletions tests/client/test_logging_callback.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from typing import Literal

import mcp_types as types
import pytest
from mcp_types import (
LoggingMessageNotificationParams,
TextContent,
)

from mcp import Client
from mcp.client import IncomingMessage
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.session import RequestResponder


class LoggingCollector:
Expand Down Expand Up @@ -55,9 +54,7 @@ async def test_tool_with_log_dict(
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

Expand Down
6 changes: 2 additions & 4 deletions tests/client/test_notification_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
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

Expand Down Expand Up @@ -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
Expand Down
13 changes: 4 additions & 9 deletions tests/client/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,14 @@
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
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

Expand Down Expand Up @@ -123,9 +122,7 @@ async def mock_server():
)

# 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

Expand Down Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion tests/interaction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 2 additions & 14 deletions tests/interaction/_helpers.py
Original file line number Diff line number Diff line change
@@ -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.
"""

Comment thread
maxisbey marked this conversation as resolved.
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:
Expand Down
3 changes: 1 addition & 2 deletions tests/interaction/lowlevel/test_cancellation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,12 @@
)

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
Expand Down
Loading
Loading