From 0a719eaa591b5453cd29d55ade3d10f186c39b39 Mon Sep 17 00:00:00 2001 From: Omar Yusuf Abdi Date: Thu, 12 Mar 2026 00:41:46 +0100 Subject: [PATCH 1/3] feat: add client callbacks for list_changed notifications The ClientSession._received_notification method silently dropped ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Add optional callback parameters following the existing logging_callback pattern so clients can react when the server signals that its tool, resource, or prompt lists have changed. Github-Issue: #2107 --- src/mcp/client/client.py | 22 +++- src/mcp/client/session.py | 20 +++ tests/client/test_list_changed_callback.py | 136 +++++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 tests/client/test_list_changed_callback.py diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 7dc67c584..9144043b6 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -8,7 +8,15 @@ 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, + ListChangedFnT, + ListRootsFnT, + LoggingFnT, + MessageHandlerFnT, + SamplingFnT, +) from mcp.client.streamable_http import streamable_http_client from mcp.server import Server from mcp.server.mcpserver import MCPServer @@ -95,6 +103,15 @@ async def main(): elicitation_callback: ElicitationFnT | None = None """Callback for handling elicitation requests.""" + tools_list_changed_callback: ListChangedFnT | None = None + """Callback invoked when the server sends a tools/list_changed notification.""" + + resources_list_changed_callback: ListChangedFnT | None = None + """Callback invoked when the server sends a resources/list_changed notification.""" + + prompts_list_changed_callback: ListChangedFnT | None = None + """Callback invoked when the server sends a prompts/list_changed notification.""" + _session: ClientSession | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None) _transport: Transport = field(init=False) @@ -126,6 +143,9 @@ async def __aenter__(self) -> Client: message_handler=self.message_handler, client_info=self.client_info, elicitation_callback=self.elicitation_callback, + tools_list_changed_callback=self.tools_list_changed_callback, + resources_list_changed_callback=self.resources_list_changed_callback, + prompts_list_changed_callback=self.prompts_list_changed_callback, ) ) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index a0ca751bd..84ef83421 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -43,6 +43,10 @@ async def __call__( ) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch +class ListChangedFnT(Protocol): + async def __call__(self) -> None: ... # pragma: no branch + + class LoggingFnT(Protocol): async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch @@ -95,6 +99,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) @@ -118,6 +126,9 @@ def __init__( logging_callback: LoggingFnT | None = None, message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, + tools_list_changed_callback: ListChangedFnT | None = None, + resources_list_changed_callback: ListChangedFnT | None = None, + prompts_list_changed_callback: ListChangedFnT | None = None, *, sampling_capabilities: types.SamplingCapability | None = None, experimental_task_handlers: ExperimentalTaskHandlers | None = None, @@ -130,6 +141,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._tools_list_changed_callback = tools_list_changed_callback or _default_list_changed_callback + self._resources_list_changed_callback = resources_list_changed_callback or _default_list_changed_callback + self._prompts_list_changed_callback = prompts_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 +484,12 @@ async def _received_notification(self, notification: types.ServerNotification) - match notification: case types.LoggingMessageNotification(params=params): await self._logging_callback(params) + case types.ToolListChangedNotification(): + await self._tools_list_changed_callback() + case types.ResourceListChangedNotification(): + await self._resources_list_changed_callback() + case types.PromptListChangedNotification(): + await self._prompts_list_changed_callback() case types.ElicitCompleteNotification(params=params): # Handle elicitation completion notification # Clients MAY use this to retry requests or update UI diff --git a/tests/client/test_list_changed_callback.py b/tests/client/test_list_changed_callback.py new file mode 100644 index 000000000..21c70ec9c --- /dev/null +++ b/tests/client/test_list_changed_callback.py @@ -0,0 +1,136 @@ +"""Tests for tools/resources/prompts list_changed notification callbacks.""" + +import anyio +import pytest + +from mcp import Client, types +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.session import RequestResponder +from mcp.types import TextContent + +pytestmark = pytest.mark.anyio + + +async def test_tools_list_changed_callback(): + """Verify that the client invokes the tools_list_changed_callback when + the server sends a notifications/tools/list_changed notification.""" + server = MCPServer("test") + received = anyio.Event() + + async def on_tools_list_changed() -> None: + received.set() + + @server.tool("trigger_tool_change") + async def trigger_tool_change(ctx: Context) -> str: + await ctx.session.send_tool_list_changed() + return "triggered" + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no cover + raise message + + async with Client( + server, + tools_list_changed_callback=on_tools_list_changed, + message_handler=message_handler, + ) as client: + result = await client.call_tool("trigger_tool_change", {}) + assert result.is_error is False + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "triggered" + + with anyio.fail_after(5): + await received.wait() + + +async def test_resources_list_changed_callback(): + """Verify that the client invokes the resources_list_changed_callback when + the server sends a notifications/resources/list_changed notification.""" + server = MCPServer("test") + received = anyio.Event() + + async def on_resources_list_changed() -> None: + received.set() + + @server.tool("trigger_resource_change") + async def trigger_resource_change(ctx: Context) -> str: + # Notify clients that the resource list has changed + await ctx.session.send_resource_list_changed() + return "triggered" + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no cover + raise message + + async with Client( + server, + resources_list_changed_callback=on_resources_list_changed, + message_handler=message_handler, + ) as client: + result = await client.call_tool("trigger_resource_change", {}) + assert result.is_error is False + + with anyio.fail_after(5): + await received.wait() + + +async def test_prompts_list_changed_callback(): + """Verify that the client invokes the prompts_list_changed_callback when + the server sends a notifications/prompts/list_changed notification.""" + server = MCPServer("test") + received = anyio.Event() + + async def on_prompts_list_changed() -> None: + received.set() + + @server.tool("trigger_prompt_change") + async def trigger_prompt_change(ctx: Context) -> str: + await ctx.session.send_prompt_list_changed() + return "triggered" + + async def message_handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): # pragma: no cover + raise message + + async with Client( + server, + prompts_list_changed_callback=on_prompts_list_changed, + message_handler=message_handler, + ) as client: + result = await client.call_tool("trigger_prompt_change", {}) + assert result.is_error is False + + with anyio.fail_after(5): + await received.wait() + + +async def test_list_changed_callbacks_not_called_without_notification(): + """Verify that list_changed callbacks are NOT invoked when + no list_changed notification is sent.""" + server = MCPServer("test") + called = False + + async def should_not_be_called() -> None: + nonlocal called + called = True # pragma: no cover + + @server.tool("normal_tool") + async def normal_tool() -> str: + return "ok" + + async with Client( + server, + tools_list_changed_callback=should_not_be_called, + resources_list_changed_callback=should_not_be_called, + prompts_list_changed_callback=should_not_be_called, + ) as client: + result = await client.call_tool("normal_tool", {}) + assert result.is_error is False + + assert not called From 694ec2c4b536351f113c277a86c5dce32c15aef4 Mon Sep 17 00:00:00 2001 From: Omar Yusuf Abdi Date: Thu, 12 Mar 2026 00:43:16 +0100 Subject: [PATCH 2/3] chore: remove unnecessary comment in test Github-Issue: #2107 --- tests/client/test_list_changed_callback.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/client/test_list_changed_callback.py b/tests/client/test_list_changed_callback.py index 21c70ec9c..1fa3f0ebe 100644 --- a/tests/client/test_list_changed_callback.py +++ b/tests/client/test_list_changed_callback.py @@ -56,7 +56,6 @@ async def on_resources_list_changed() -> None: @server.tool("trigger_resource_change") async def trigger_resource_change(ctx: Context) -> str: - # Notify clients that the resource list has changed await ctx.session.send_resource_list_changed() return "triggered" From 8bd11c3025dc448dba22c6a0acaf4a7c1e6b77d0 Mon Sep 17 00:00:00 2001 From: Omar Yusuf Abdi Date: Thu, 12 Mar 2026 01:46:48 +0100 Subject: [PATCH 3/3] fix: remove stale pragma no-cover from server send_*_list_changed methods These methods are now exercised by the new list_changed callback tests, so the strict-no-cover CI check rejects the pragma. Github-Issue: #2107 --- src/mcp/server/session.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 759d2131a..1f3ce6170 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -474,15 +474,15 @@ async def send_progress_notification( related_request_id, ) - async def send_resource_list_changed(self) -> None: # pragma: no cover + async def send_resource_list_changed(self) -> None: """Send a resource list changed notification.""" await self.send_notification(types.ResourceListChangedNotification()) - async def send_tool_list_changed(self) -> None: # pragma: no cover + async def send_tool_list_changed(self) -> None: """Send a tool list changed notification.""" await self.send_notification(types.ToolListChangedNotification()) - async def send_prompt_list_changed(self) -> None: # pragma: no cover + async def send_prompt_list_changed(self) -> None: """Send a prompt list changed notification.""" await self.send_notification(types.PromptListChangedNotification())