From 198bf0182ac76bf6880f085705822b78e066c1c9 Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 19:22:08 +0100 Subject: [PATCH 1/7] feat(client): handle list_changed notifications via callbacks Add support for ToolListChangedNotification, PromptListChangedNotification, and ResourceListChangedNotification in ClientSession._received_notification(). Previously these notifications were silently dropped, making it impossible for clients to react when a server's tool, prompt, or resource lists changed dynamically. Changes: - Add ToolListChangedFnT, PromptListChangedFnT, ResourceListChangedFnT callback Protocol types in session.py - Accept optional callbacks in ClientSession.__init__() (keyword-only) - Dispatch to callbacks in _received_notification() with try-except safety - Expose callbacks in Client dataclass and ClientSessionParameters - Pass callbacks through to ClientSession in Client.__aenter__() and ClientSessionGroup._establish_session() Fixes #2107 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/mcp/client/client.py | 24 +- src/mcp/client/session.py | 37 +++ src/mcp/client/session_group.py | 17 +- tests/client/test_list_changed_callbacks.py | 291 ++++++++++++++++++++ tests/client/test_session_group.py | 3 + 5 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 tests/client/test_list_changed_callbacks.py diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 7dc67c584..958ecfb31 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -8,7 +8,17 @@ from mcp.client._memory import InMemoryTransport from mcp.client._transport import Transport -from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT +from mcp.client.session import ( + ClientSession, + ElicitationFnT, + ListRootsFnT, + LoggingFnT, + MessageHandlerFnT, + PromptListChangedFnT, + ResourceListChangedFnT, + SamplingFnT, + ToolListChangedFnT, +) from mcp.client.streamable_http import streamable_http_client from mcp.server import Server from mcp.server.mcpserver import MCPServer @@ -95,6 +105,15 @@ async def main(): elicitation_callback: ElicitationFnT | None = None """Callback for handling elicitation requests.""" + tool_list_changed_callback: ToolListChangedFnT | None = None + """Callback invoked when the server signals its tool list has changed.""" + + prompt_list_changed_callback: PromptListChangedFnT | None = None + """Callback invoked when the server signals its prompt list has changed.""" + + resource_list_changed_callback: ResourceListChangedFnT | None = None + """Callback invoked when the server signals its resource list has changed.""" + _session: ClientSession | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None) _transport: Transport = field(init=False) @@ -126,6 +145,9 @@ async def __aenter__(self) -> Client: message_handler=self.message_handler, client_info=self.client_info, elicitation_callback=self.elicitation_callback, + tool_list_changed_callback=self.tool_list_changed_callback, + prompt_list_changed_callback=self.prompt_list_changed_callback, + resource_list_changed_callback=self.resource_list_changed_callback, ) ) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index a0ca751bd..12b75b585 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -47,6 +47,18 @@ class LoggingFnT(Protocol): async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch +class ResourceListChangedFnT(Protocol): + async def __call__(self) -> None: ... # pragma: no branch + + +class ToolListChangedFnT(Protocol): + async def __call__(self) -> None: ... # pragma: no branch + + +class PromptListChangedFnT(Protocol): + async def __call__(self) -> None: ... # pragma: no branch + + class MessageHandlerFnT(Protocol): async def __call__( self, @@ -95,6 +107,10 @@ async def _default_logging_callback( pass +async def _default_list_changed_callback() -> None: + pass + + ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) @@ -121,6 +137,9 @@ def __init__( *, sampling_capabilities: types.SamplingCapability | None = None, experimental_task_handlers: ExperimentalTaskHandlers | None = None, + tool_list_changed_callback: ToolListChangedFnT | None = None, + prompt_list_changed_callback: PromptListChangedFnT | None = None, + resource_list_changed_callback: ResourceListChangedFnT | None = None, ) -> None: super().__init__(read_stream, write_stream, read_timeout_seconds=read_timeout_seconds) self._client_info = client_info or DEFAULT_CLIENT_INFO @@ -130,6 +149,9 @@ def __init__( self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback self._message_handler = message_handler or _default_message_handler + self._tool_list_changed_callback = tool_list_changed_callback or _default_list_changed_callback + self._prompt_list_changed_callback = prompt_list_changed_callback or _default_list_changed_callback + self._resource_list_changed_callback = resource_list_changed_callback or _default_list_changed_callback self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} self._server_capabilities: types.ServerCapabilities | None = None self._experimental_features: ExperimentalClientFeatures | None = None @@ -470,6 +492,21 @@ async def _received_notification(self, notification: types.ServerNotification) - match notification: case types.LoggingMessageNotification(params=params): await self._logging_callback(params) + case types.ToolListChangedNotification(): + try: + await self._tool_list_changed_callback() + except Exception: + logger.exception("Tool list changed callback raised an exception") + case types.PromptListChangedNotification(): + try: + await self._prompt_list_changed_callback() + except Exception: + logger.exception("Prompt list changed callback raised an exception") + case types.ResourceListChangedNotification(): + try: + await self._resource_list_changed_callback() + except Exception: + logger.exception("Resource list changed callback raised an exception") case types.ElicitCompleteNotification(params=params): # Handle elicitation completion notification # Clients MAY use this to retry requests or update UI diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 961021264..bd3516347 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -20,7 +20,16 @@ import mcp from mcp import types -from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT +from mcp.client.session import ( + ElicitationFnT, + ListRootsFnT, + LoggingFnT, + MessageHandlerFnT, + PromptListChangedFnT, + ResourceListChangedFnT, + SamplingFnT, + ToolListChangedFnT, +) from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters from mcp.client.streamable_http import streamable_http_client @@ -80,6 +89,9 @@ class ClientSessionParameters: logging_callback: LoggingFnT | None = None message_handler: MessageHandlerFnT | None = None client_info: types.Implementation | None = None + tool_list_changed_callback: ToolListChangedFnT | None = None + prompt_list_changed_callback: PromptListChangedFnT | None = None + resource_list_changed_callback: ResourceListChangedFnT | None = None class ClientSessionGroup: @@ -310,6 +322,9 @@ async def _establish_session( logging_callback=session_params.logging_callback, message_handler=session_params.message_handler, client_info=session_params.client_info, + tool_list_changed_callback=session_params.tool_list_changed_callback, + prompt_list_changed_callback=session_params.prompt_list_changed_callback, + resource_list_changed_callback=session_params.resource_list_changed_callback, ) ) diff --git a/tests/client/test_list_changed_callbacks.py b/tests/client/test_list_changed_callbacks.py new file mode 100644 index 000000000..3b9a26690 --- /dev/null +++ b/tests/client/test_list_changed_callbacks.py @@ -0,0 +1,291 @@ +"""Tests for list_changed notification callbacks in ClientSession.""" + +import anyio +import pytest + +from mcp import types +from mcp.client.session import ClientSession +from mcp.server import Server, ServerRequestContext +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage + +pytestmark = pytest.mark.anyio + + +async def test_tool_list_changed_callback(): + """Verify that the client invokes the tool_list_changed callback when + the server sends a notifications/tools/list_changed notification.""" + callback_called = anyio.Event() + + async def on_tools_changed() -> None: + callback_called.set() + + server = Server( + name="ListChangedServer", + on_list_tools=lambda _ctx, _params: types.ListToolsResult(tools=[]), + ) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) + + async with anyio.create_task_group() as tg: + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="ListChangedServer", + server_version="0.1.0", + capabilities=server.get_capabilities( + NotificationOptions(tools_changed=True), {} + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}) + + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + tool_list_changed_callback=on_tools_changed, + ) as session: + await session.initialize() + + # Have the server send a tool list changed notification directly + await server_to_client_send.send( + SessionMessage( + message=types.JSONRPCNotification( + jsonrpc="2.0", + **types.ToolListChangedNotification().model_dump( + by_alias=True, mode="json", exclude_none=True + ), + ), + ) + ) + + with anyio.fail_after(2): + await callback_called.wait() + + tg.cancel_scope.cancel() + + +async def test_prompt_list_changed_callback(): + """Verify the prompt_list_changed callback is invoked.""" + callback_called = anyio.Event() + + async def on_prompts_changed() -> None: + callback_called.set() + + server = Server(name="ListChangedServer") + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) + + async with anyio.create_task_group() as tg: + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="ListChangedServer", + server_version="0.1.0", + capabilities=server.get_capabilities( + NotificationOptions(prompts_changed=True), {} + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}) + + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + prompt_list_changed_callback=on_prompts_changed, + ) as session: + await session.initialize() + + await server_to_client_send.send( + SessionMessage( + message=types.JSONRPCNotification( + jsonrpc="2.0", + **types.PromptListChangedNotification().model_dump( + by_alias=True, mode="json", exclude_none=True + ), + ), + ) + ) + + with anyio.fail_after(2): + await callback_called.wait() + + tg.cancel_scope.cancel() + + +async def test_resource_list_changed_callback(): + """Verify the resource_list_changed callback is invoked.""" + callback_called = anyio.Event() + + async def on_resources_changed() -> None: + callback_called.set() + + server = Server(name="ListChangedServer") + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) + + async with anyio.create_task_group() as tg: + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="ListChangedServer", + server_version="0.1.0", + capabilities=server.get_capabilities( + NotificationOptions(resources_changed=True), {} + ), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}) + + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + resource_list_changed_callback=on_resources_changed, + ) as session: + await session.initialize() + + await server_to_client_send.send( + SessionMessage( + message=types.JSONRPCNotification( + jsonrpc="2.0", + **types.ResourceListChangedNotification().model_dump( + by_alias=True, mode="json", exclude_none=True + ), + ), + ) + ) + + with anyio.fail_after(2): + await callback_called.wait() + + tg.cancel_scope.cancel() + + +async def test_list_changed_default_no_error(): + """Verify that without callbacks, list_changed notifications are handled + silently (no errors, no hangs).""" + server = Server(name="ListChangedServer") + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) + + async with anyio.create_task_group() as tg: + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="ListChangedServer", + server_version="0.1.0", + capabilities=server.get_capabilities(NotificationOptions(), {}), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}) + + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session: + await session.initialize() + + # Send all three list_changed notifications — none should cause errors + for notification_cls in ( + types.ToolListChangedNotification, + types.PromptListChangedNotification, + types.ResourceListChangedNotification, + ): + await server_to_client_send.send( + SessionMessage( + message=types.JSONRPCNotification( + jsonrpc="2.0", + **notification_cls().model_dump( + by_alias=True, mode="json", exclude_none=True + ), + ), + ) + ) + + # Give the session a moment to process + await anyio.sleep(0.1) + + tg.cancel_scope.cancel() + + +async def test_callback_exception_does_not_crash_session(): + """Verify that an exception in a list_changed callback is logged but does + not crash the client session.""" + + async def bad_callback() -> None: + raise RuntimeError("boom") + + server = Server(name="ListChangedServer") + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) + + async with anyio.create_task_group() as tg: + + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="ListChangedServer", + server_version="0.1.0", + capabilities=server.get_capabilities(NotificationOptions(), {}), + ), + ) as server_session: + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}) + + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + tool_list_changed_callback=bad_callback, + ) as session: + await session.initialize() + + await server_to_client_send.send( + SessionMessage( + message=types.JSONRPCNotification( + jsonrpc="2.0", + **types.ToolListChangedNotification().model_dump( + by_alias=True, mode="json", exclude_none=True + ), + ), + ) + ) + + # Session should still be alive — verify by listing tools + await anyio.sleep(0.1) + + tg.cancel_scope.cancel() diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 6a58b39f3..cffdc96b2 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -378,6 +378,9 @@ async def test_client_session_group_establish_session_parameterized( logging_callback=None, message_handler=None, client_info=None, + tool_list_changed_callback=None, + prompt_list_changed_callback=None, + resource_list_changed_callback=None, ) mock_raw_session_cm.__aenter__.assert_awaited_once() mock_entered_session.initialize.assert_awaited_once() From 87c2fbddcec336b85192c040c37178dcc32a5f7c Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 19:33:47 +0100 Subject: [PATCH 2/7] style: fix ruff format and remove unused import in test file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/client/test_list_changed_callbacks.py | 26 ++++++--------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/client/test_list_changed_callbacks.py b/tests/client/test_list_changed_callbacks.py index 3b9a26690..45e4d23d6 100644 --- a/tests/client/test_list_changed_callbacks.py +++ b/tests/client/test_list_changed_callbacks.py @@ -5,7 +5,7 @@ from mcp import types from mcp.client.session import ClientSession -from mcp.server import Server, ServerRequestContext +from mcp.server import Server from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession @@ -39,9 +39,7 @@ async def run_server(): InitializationOptions( server_name="ListChangedServer", server_version="0.1.0", - capabilities=server.get_capabilities( - NotificationOptions(tools_changed=True), {} - ), + capabilities=server.get_capabilities(NotificationOptions(tools_changed=True), {}), ), ) as server_session: async for message in server_session.incoming_messages: @@ -61,9 +59,7 @@ async def run_server(): SessionMessage( message=types.JSONRPCNotification( jsonrpc="2.0", - **types.ToolListChangedNotification().model_dump( - by_alias=True, mode="json", exclude_none=True - ), + **types.ToolListChangedNotification().model_dump(by_alias=True, mode="json", exclude_none=True), ), ) ) @@ -95,9 +91,7 @@ async def run_server(): InitializationOptions( server_name="ListChangedServer", server_version="0.1.0", - capabilities=server.get_capabilities( - NotificationOptions(prompts_changed=True), {} - ), + capabilities=server.get_capabilities(NotificationOptions(prompts_changed=True), {}), ), ) as server_session: async for message in server_session.incoming_messages: @@ -150,9 +144,7 @@ async def run_server(): InitializationOptions( server_name="ListChangedServer", server_version="0.1.0", - capabilities=server.get_capabilities( - NotificationOptions(resources_changed=True), {} - ), + capabilities=server.get_capabilities(NotificationOptions(resources_changed=True), {}), ), ) as server_session: async for message in server_session.incoming_messages: @@ -225,9 +217,7 @@ async def run_server(): SessionMessage( message=types.JSONRPCNotification( jsonrpc="2.0", - **notification_cls().model_dump( - by_alias=True, mode="json", exclude_none=True - ), + **notification_cls().model_dump(by_alias=True, mode="json", exclude_none=True), ), ) ) @@ -278,9 +268,7 @@ async def run_server(): SessionMessage( message=types.JSONRPCNotification( jsonrpc="2.0", - **types.ToolListChangedNotification().model_dump( - by_alias=True, mode="json", exclude_none=True - ), + **types.ToolListChangedNotification().model_dump(by_alias=True, mode="json", exclude_none=True), ), ) ) From 5d48dd4fa58fba737850f4364d72c0c3798afc51 Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 19:42:24 +0100 Subject: [PATCH 3/7] fix: make test handler async & cover all exception branches - Make on_list_tools handler async to satisfy pyright type checking - Extend exception test to cover prompt and resource callbacks (hits lines 503-504 and 508-509 for 100% coverage) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/client/test_list_changed_callbacks.py | 30 ++++++++++++++------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/client/test_list_changed_callbacks.py b/tests/client/test_list_changed_callbacks.py index 45e4d23d6..47dd46275 100644 --- a/tests/client/test_list_changed_callbacks.py +++ b/tests/client/test_list_changed_callbacks.py @@ -22,9 +22,12 @@ async def test_tool_list_changed_callback(): async def on_tools_changed() -> None: callback_called.set() + async def _list_tools(_ctx: object, _params: object) -> types.ListToolsResult: + return types.ListToolsResult(tools=[]) + server = Server( name="ListChangedServer", - on_list_tools=lambda _ctx, _params: types.ListToolsResult(tools=[]), + on_list_tools=_list_tools, ) server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) @@ -261,19 +264,28 @@ async def run_server(): server_to_client_receive, client_to_server_send, tool_list_changed_callback=bad_callback, + prompt_list_changed_callback=bad_callback, + resource_list_changed_callback=bad_callback, ) as session: await session.initialize() - await server_to_client_send.send( - SessionMessage( - message=types.JSONRPCNotification( - jsonrpc="2.0", - **types.ToolListChangedNotification().model_dump(by_alias=True, mode="json", exclude_none=True), - ), + # Send all three notification types — all callbacks will raise, + # but the session should survive. + for notification_cls in ( + types.ToolListChangedNotification, + types.PromptListChangedNotification, + types.ResourceListChangedNotification, + ): + await server_to_client_send.send( + SessionMessage( + message=types.JSONRPCNotification( + jsonrpc="2.0", + **notification_cls().model_dump(by_alias=True, mode="json", exclude_none=True), + ), + ) ) - ) - # Session should still be alive — verify by listing tools + # Session should still be alive — verify by waiting for processing await anyio.sleep(0.1) tg.cancel_scope.cancel() From 62725e2e089c121357129b530c7169771b7267cf Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 19:45:05 +0100 Subject: [PATCH 4/7] fix: add pragma no cover for unused test helper callback The _list_tools callback is required to construct the Server but is not actually invoked during the notification test. Marking with pragma: no cover fixes the 99.99% -> 100% coverage gate. --- tests/client/test_list_changed_callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_list_changed_callbacks.py b/tests/client/test_list_changed_callbacks.py index 47dd46275..a209e8b89 100644 --- a/tests/client/test_list_changed_callbacks.py +++ b/tests/client/test_list_changed_callbacks.py @@ -23,7 +23,7 @@ async def on_tools_changed() -> None: callback_called.set() async def _list_tools(_ctx: object, _params: object) -> types.ListToolsResult: - return types.ListToolsResult(tools=[]) + return types.ListToolsResult(tools=[]) # pragma: no cover server = Server( name="ListChangedServer", From fc164609f4c37eaa561d949ec9ada3f3f624fa1c Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 20:05:48 +0100 Subject: [PATCH 5/7] fix: move cancel_scope.cancel() inside ClientSession for 3.11 coverage The tg.cancel_scope.cancel() calls placed after the 'async with ClientSession' block were not being tracked by coverage on Python 3.11, causing 5 statement misses and dropping test file coverage below 100%. Moving the cancel calls inside the ClientSession context manager ensures they execute while coverage tracking is still active, achieving 100% statement and branch coverage on all Python versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/client/test_list_changed_callbacks.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/client/test_list_changed_callbacks.py b/tests/client/test_list_changed_callbacks.py index a209e8b89..edb8231b2 100644 --- a/tests/client/test_list_changed_callbacks.py +++ b/tests/client/test_list_changed_callbacks.py @@ -70,7 +70,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() async def test_prompt_list_changed_callback(): @@ -123,7 +123,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() async def test_resource_list_changed_callback(): @@ -176,7 +176,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() async def test_list_changed_default_no_error(): @@ -228,7 +228,7 @@ async def run_server(): # Give the session a moment to process await anyio.sleep(0.1) - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() async def test_callback_exception_does_not_crash_session(): @@ -288,4 +288,4 @@ async def run_server(): # Session should still be alive — verify by waiting for processing await anyio.sleep(0.1) - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() From 50416a1aa0e42b14350728d271edef2b78576a6e Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 20:05:58 +0100 Subject: [PATCH 6/7] fix: add pragma no cover to cancel_scope lines for Python 3.11 coverage On Python 3.11, the tg.cancel_scope.cancel() lines are not hit during coverage measurement due to anyio task group cancellation semantics. These are cleanup lines that run as part of test teardown. --- tests/client/test_list_changed_callbacks.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/client/test_list_changed_callbacks.py b/tests/client/test_list_changed_callbacks.py index edb8231b2..3f1ff7426 100644 --- a/tests/client/test_list_changed_callbacks.py +++ b/tests/client/test_list_changed_callbacks.py @@ -70,7 +70,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() # pragma: no cover async def test_prompt_list_changed_callback(): @@ -123,7 +123,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() # pragma: no cover async def test_resource_list_changed_callback(): @@ -176,7 +176,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() # pragma: no cover async def test_list_changed_default_no_error(): @@ -228,7 +228,7 @@ async def run_server(): # Give the session a moment to process await anyio.sleep(0.1) - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() # pragma: no cover async def test_callback_exception_does_not_crash_session(): @@ -288,4 +288,4 @@ async def run_server(): # Session should still be alive — verify by waiting for processing await anyio.sleep(0.1) - tg.cancel_scope.cancel() + tg.cancel_scope.cancel() # pragma: no cover From 943cde73aab8fcaf9fc765ddb89b257956165356 Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 20:21:55 +0100 Subject: [PATCH 7/7] fix: remove unnecessary pragma no cover from cancel_scope lines The strict-no-cover check confirms these lines are covered across all Python versions in CI. --- tests/client/test_list_changed_callbacks.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/client/test_list_changed_callbacks.py b/tests/client/test_list_changed_callbacks.py index 3f1ff7426..edb8231b2 100644 --- a/tests/client/test_list_changed_callbacks.py +++ b/tests/client/test_list_changed_callbacks.py @@ -70,7 +70,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() # pragma: no cover + tg.cancel_scope.cancel() async def test_prompt_list_changed_callback(): @@ -123,7 +123,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() # pragma: no cover + tg.cancel_scope.cancel() async def test_resource_list_changed_callback(): @@ -176,7 +176,7 @@ async def run_server(): with anyio.fail_after(2): await callback_called.wait() - tg.cancel_scope.cancel() # pragma: no cover + tg.cancel_scope.cancel() async def test_list_changed_default_no_error(): @@ -228,7 +228,7 @@ async def run_server(): # Give the session a moment to process await anyio.sleep(0.1) - tg.cancel_scope.cancel() # pragma: no cover + tg.cancel_scope.cancel() async def test_callback_exception_does_not_crash_session(): @@ -288,4 +288,4 @@ async def run_server(): # Session should still be alive — verify by waiting for processing await anyio.sleep(0.1) - tg.cancel_scope.cancel() # pragma: no cover + tg.cancel_scope.cancel()