Skip to content

Commit 0a719ea

Browse files
committed
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
1 parent 62eb08e commit 0a719ea

File tree

3 files changed

+177
-1
lines changed

3 files changed

+177
-1
lines changed

src/mcp/client/client.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88

99
from mcp.client._memory import InMemoryTransport
1010
from mcp.client._transport import Transport
11-
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
11+
from mcp.client.session import (
12+
ClientSession,
13+
ElicitationFnT,
14+
ListChangedFnT,
15+
ListRootsFnT,
16+
LoggingFnT,
17+
MessageHandlerFnT,
18+
SamplingFnT,
19+
)
1220
from mcp.client.streamable_http import streamable_http_client
1321
from mcp.server import Server
1422
from mcp.server.mcpserver import MCPServer
@@ -95,6 +103,15 @@ async def main():
95103
elicitation_callback: ElicitationFnT | None = None
96104
"""Callback for handling elicitation requests."""
97105

106+
tools_list_changed_callback: ListChangedFnT | None = None
107+
"""Callback invoked when the server sends a tools/list_changed notification."""
108+
109+
resources_list_changed_callback: ListChangedFnT | None = None
110+
"""Callback invoked when the server sends a resources/list_changed notification."""
111+
112+
prompts_list_changed_callback: ListChangedFnT | None = None
113+
"""Callback invoked when the server sends a prompts/list_changed notification."""
114+
98115
_session: ClientSession | None = field(init=False, default=None)
99116
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
100117
_transport: Transport = field(init=False)
@@ -126,6 +143,9 @@ async def __aenter__(self) -> Client:
126143
message_handler=self.message_handler,
127144
client_info=self.client_info,
128145
elicitation_callback=self.elicitation_callback,
146+
tools_list_changed_callback=self.tools_list_changed_callback,
147+
resources_list_changed_callback=self.resources_list_changed_callback,
148+
prompts_list_changed_callback=self.prompts_list_changed_callback,
129149
)
130150
)
131151

src/mcp/client/session.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ async def __call__(
4343
) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch
4444

4545

46+
class ListChangedFnT(Protocol):
47+
async def __call__(self) -> None: ... # pragma: no branch
48+
49+
4650
class LoggingFnT(Protocol):
4751
async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch
4852

@@ -95,6 +99,10 @@ async def _default_logging_callback(
9599
pass
96100

97101

102+
async def _default_list_changed_callback() -> None:
103+
pass
104+
105+
98106
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)
99107

100108

@@ -118,6 +126,9 @@ def __init__(
118126
logging_callback: LoggingFnT | None = None,
119127
message_handler: MessageHandlerFnT | None = None,
120128
client_info: types.Implementation | None = None,
129+
tools_list_changed_callback: ListChangedFnT | None = None,
130+
resources_list_changed_callback: ListChangedFnT | None = None,
131+
prompts_list_changed_callback: ListChangedFnT | None = None,
121132
*,
122133
sampling_capabilities: types.SamplingCapability | None = None,
123134
experimental_task_handlers: ExperimentalTaskHandlers | None = None,
@@ -130,6 +141,9 @@ def __init__(
130141
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
131142
self._logging_callback = logging_callback or _default_logging_callback
132143
self._message_handler = message_handler or _default_message_handler
144+
self._tools_list_changed_callback = tools_list_changed_callback or _default_list_changed_callback
145+
self._resources_list_changed_callback = resources_list_changed_callback or _default_list_changed_callback
146+
self._prompts_list_changed_callback = prompts_list_changed_callback or _default_list_changed_callback
133147
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
134148
self._server_capabilities: types.ServerCapabilities | None = None
135149
self._experimental_features: ExperimentalClientFeatures | None = None
@@ -470,6 +484,12 @@ async def _received_notification(self, notification: types.ServerNotification) -
470484
match notification:
471485
case types.LoggingMessageNotification(params=params):
472486
await self._logging_callback(params)
487+
case types.ToolListChangedNotification():
488+
await self._tools_list_changed_callback()
489+
case types.ResourceListChangedNotification():
490+
await self._resources_list_changed_callback()
491+
case types.PromptListChangedNotification():
492+
await self._prompts_list_changed_callback()
473493
case types.ElicitCompleteNotification(params=params):
474494
# Handle elicitation completion notification
475495
# Clients MAY use this to retry requests or update UI
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Tests for tools/resources/prompts list_changed notification callbacks."""
2+
3+
import anyio
4+
import pytest
5+
6+
from mcp import Client, types
7+
from mcp.server.mcpserver import Context, MCPServer
8+
from mcp.shared.session import RequestResponder
9+
from mcp.types import TextContent
10+
11+
pytestmark = pytest.mark.anyio
12+
13+
14+
async def test_tools_list_changed_callback():
15+
"""Verify that the client invokes the tools_list_changed_callback when
16+
the server sends a notifications/tools/list_changed notification."""
17+
server = MCPServer("test")
18+
received = anyio.Event()
19+
20+
async def on_tools_list_changed() -> None:
21+
received.set()
22+
23+
@server.tool("trigger_tool_change")
24+
async def trigger_tool_change(ctx: Context) -> str:
25+
await ctx.session.send_tool_list_changed()
26+
return "triggered"
27+
28+
async def message_handler(
29+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
30+
) -> None:
31+
if isinstance(message, Exception): # pragma: no cover
32+
raise message
33+
34+
async with Client(
35+
server,
36+
tools_list_changed_callback=on_tools_list_changed,
37+
message_handler=message_handler,
38+
) as client:
39+
result = await client.call_tool("trigger_tool_change", {})
40+
assert result.is_error is False
41+
assert isinstance(result.content[0], TextContent)
42+
assert result.content[0].text == "triggered"
43+
44+
with anyio.fail_after(5):
45+
await received.wait()
46+
47+
48+
async def test_resources_list_changed_callback():
49+
"""Verify that the client invokes the resources_list_changed_callback when
50+
the server sends a notifications/resources/list_changed notification."""
51+
server = MCPServer("test")
52+
received = anyio.Event()
53+
54+
async def on_resources_list_changed() -> None:
55+
received.set()
56+
57+
@server.tool("trigger_resource_change")
58+
async def trigger_resource_change(ctx: Context) -> str:
59+
# Notify clients that the resource list has changed
60+
await ctx.session.send_resource_list_changed()
61+
return "triggered"
62+
63+
async def message_handler(
64+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
65+
) -> None:
66+
if isinstance(message, Exception): # pragma: no cover
67+
raise message
68+
69+
async with Client(
70+
server,
71+
resources_list_changed_callback=on_resources_list_changed,
72+
message_handler=message_handler,
73+
) as client:
74+
result = await client.call_tool("trigger_resource_change", {})
75+
assert result.is_error is False
76+
77+
with anyio.fail_after(5):
78+
await received.wait()
79+
80+
81+
async def test_prompts_list_changed_callback():
82+
"""Verify that the client invokes the prompts_list_changed_callback when
83+
the server sends a notifications/prompts/list_changed notification."""
84+
server = MCPServer("test")
85+
received = anyio.Event()
86+
87+
async def on_prompts_list_changed() -> None:
88+
received.set()
89+
90+
@server.tool("trigger_prompt_change")
91+
async def trigger_prompt_change(ctx: Context) -> str:
92+
await ctx.session.send_prompt_list_changed()
93+
return "triggered"
94+
95+
async def message_handler(
96+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
97+
) -> None:
98+
if isinstance(message, Exception): # pragma: no cover
99+
raise message
100+
101+
async with Client(
102+
server,
103+
prompts_list_changed_callback=on_prompts_list_changed,
104+
message_handler=message_handler,
105+
) as client:
106+
result = await client.call_tool("trigger_prompt_change", {})
107+
assert result.is_error is False
108+
109+
with anyio.fail_after(5):
110+
await received.wait()
111+
112+
113+
async def test_list_changed_callbacks_not_called_without_notification():
114+
"""Verify that list_changed callbacks are NOT invoked when
115+
no list_changed notification is sent."""
116+
server = MCPServer("test")
117+
called = False
118+
119+
async def should_not_be_called() -> None:
120+
nonlocal called
121+
called = True # pragma: no cover
122+
123+
@server.tool("normal_tool")
124+
async def normal_tool() -> str:
125+
return "ok"
126+
127+
async with Client(
128+
server,
129+
tools_list_changed_callback=should_not_be_called,
130+
resources_list_changed_callback=should_not_be_called,
131+
prompts_list_changed_callback=should_not_be_called,
132+
) as client:
133+
result = await client.call_tool("normal_tool", {})
134+
assert result.is_error is False
135+
136+
assert not called

0 commit comments

Comments
 (0)