1+ """Test for tool cache refresh bug with nested handler invocation (issue #1298).
2+
3+ This test verifies that cache refresh doesn't use nested handler invocation,
4+ which can disrupt async execution in streaming contexts.
5+ """
6+
7+ from typing import Any
8+
9+ import anyio
10+ import pytest
11+
12+ from mcp .client .session import ClientSession
13+ from mcp .server .lowlevel import Server
14+ from mcp .types import ListToolsRequest , TextContent , Tool
15+
16+
17+ @pytest .mark .anyio
18+ async def test_no_nested_handler_invocation_on_cache_refresh ():
19+ """Verify that cache refresh doesn't use nested handler invocation.
20+
21+ Issue #1298: Tool handlers can fail when cache refresh triggers
22+ nested handler invocation via self.request_handlers[ListToolsRequest](None),
23+ which disrupts async execution flow in streaming contexts.
24+
25+ This test verifies the fix by detecting whether nested handler
26+ invocation occurs during cache refresh.
27+ """
28+ server = Server ("test-server" )
29+
30+ # Track handler invocations
31+ handler_invocations = []
32+
33+ @server .list_tools ()
34+ async def list_tools ():
35+ # Normal tool listing
36+ await anyio .sleep (0.001 )
37+ return [
38+ Tool (
39+ name = "test_tool" ,
40+ description = "Test tool" ,
41+ inputSchema = {"type" : "object" , "properties" : {}}
42+ )
43+ ]
44+
45+ @server .call_tool ()
46+ async def call_tool (name : str , arguments : dict [str , Any ]):
47+ # Simple tool implementation
48+ return [TextContent (type = "text" , text = "Tool result" )]
49+
50+ # Intercept the ListToolsRequest handler to detect nested invocation
51+ original_handler = None
52+
53+ def setup_handler_interceptor ():
54+ nonlocal original_handler
55+ original_handler = server .request_handlers .get (ListToolsRequest )
56+
57+ async def interceptor (req ):
58+ # Track the invocation
59+ # req is None for nested invocations (the problematic pattern)
60+ # req is a proper request object for normal invocations
61+ if req is None :
62+ handler_invocations .append ("nested" )
63+ else :
64+ handler_invocations .append ("normal" )
65+
66+ # Call the original handler
67+ if original_handler :
68+ return await original_handler (req )
69+ return None
70+
71+ server .request_handlers [ListToolsRequest ] = interceptor
72+
73+ # Set up the interceptor after decorators have run
74+ setup_handler_interceptor ()
75+
76+ # Setup communication channels
77+ from anyio .streams .memory import MemoryObjectReceiveStream , MemoryObjectSendStream
78+ from mcp .shared .message import SessionMessage
79+
80+ server_to_client_send , server_to_client_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
81+ client_to_server_send , client_to_server_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
82+
83+ async def run_server ():
84+ await server .run (
85+ client_to_server_receive ,
86+ server_to_client_send ,
87+ server .create_initialization_options ()
88+ )
89+
90+ async with anyio .create_task_group () as tg :
91+ tg .start_soon (run_server )
92+
93+ async with ClientSession (server_to_client_receive , client_to_server_send ) as session :
94+ await session .initialize ()
95+
96+ # Clear the cache to force a refresh on next tool call
97+ server ._tool_cache .clear ()
98+
99+ # Make a tool call - this should trigger cache refresh
100+ result = await session .call_tool ("test_tool" , {})
101+
102+ # Verify the tool call succeeded
103+ assert result is not None
104+ assert not result .isError
105+ assert result .content [0 ].text == "Tool result"
106+
107+ # Check if nested handler invocation occurred
108+ has_nested_invocation = "nested" in handler_invocations
109+
110+ # The bug is present if nested handler invocation occurs
111+ assert not has_nested_invocation , (
112+ "Nested handler invocation detected during cache refresh. "
113+ "This pattern (calling request_handlers[ListToolsRequest](None)) "
114+ "can disrupt async execution in streaming contexts (issue #1298)."
115+ )
116+
117+ tg .cancel_scope .cancel ()
118+
119+
120+ @pytest .mark .anyio
121+ async def test_concurrent_cache_refresh_safety ():
122+ """Verify that concurrent tool calls with cache refresh work correctly.
123+
124+ Multiple concurrent tool calls that all trigger cache refresh should
125+ not cause issues or result in nested handler invocations.
126+ """
127+ server = Server ("test-server" )
128+
129+ # Track concurrent handler invocations
130+ nested_invocations = 0
131+
132+ @server .list_tools ()
133+ async def list_tools ():
134+ await anyio .sleep (0.01 ) # Simulate some async work
135+ return [
136+ Tool (
137+ name = f"tool_{ i } " ,
138+ description = f"Tool { i } " ,
139+ inputSchema = {"type" : "object" , "properties" : {}}
140+ )
141+ for i in range (3 )
142+ ]
143+
144+ @server .call_tool ()
145+ async def call_tool (name : str , arguments : dict [str , Any ]):
146+ await anyio .sleep (0.001 )
147+ return [TextContent (type = "text" , text = f"Result from { name } " )]
148+
149+ # Intercept handler to detect nested invocations
150+ original_handler = server .request_handlers .get (ListToolsRequest )
151+
152+ async def interceptor (req ):
153+ nonlocal nested_invocations
154+ if req is None :
155+ nested_invocations += 1
156+ if original_handler :
157+ return await original_handler (req )
158+ return None
159+
160+ if original_handler :
161+ server .request_handlers [ListToolsRequest ] = interceptor
162+
163+ # Setup communication
164+ from anyio .streams .memory import MemoryObjectReceiveStream , MemoryObjectSendStream
165+ from mcp .shared .message import SessionMessage
166+
167+ server_to_client_send , server_to_client_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
168+ client_to_server_send , client_to_server_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
169+
170+ async def run_server ():
171+ await server .run (
172+ client_to_server_receive ,
173+ server_to_client_send ,
174+ server .create_initialization_options ()
175+ )
176+
177+ async with anyio .create_task_group () as tg :
178+ tg .start_soon (run_server )
179+
180+ async with ClientSession (server_to_client_receive , client_to_server_send ) as session :
181+ await session .initialize ()
182+
183+ # Clear cache to force refresh
184+ server ._tool_cache .clear ()
185+
186+ # Make concurrent tool calls
187+ import asyncio
188+ results = await asyncio .gather (
189+ session .call_tool ("tool_0" , {}),
190+ session .call_tool ("tool_1" , {}),
191+ session .call_tool ("tool_2" , {}),
192+ return_exceptions = True
193+ )
194+
195+ # Verify all calls succeeded
196+ for i , result in enumerate (results ):
197+ assert not isinstance (result , Exception ), f"Tool { i } failed: { result } "
198+ assert not result .isError
199+ assert f"tool_{ i } " in result .content [0 ].text
200+
201+ # Verify no nested invocations occurred
202+ assert nested_invocations == 0 , (
203+ f"Detected { nested_invocations } nested handler invocations "
204+ "during concurrent cache refresh. This indicates the bug from "
205+ "issue #1298 is present."
206+ )
207+
208+ tg .cancel_scope .cancel ()
0 commit comments