44which can disrupt async execution in streaming contexts.
55"""
66
7- from typing import Any
7+ from typing import Any , cast
88
99import anyio
1010import pytest
1717@pytest .mark .anyio
1818async def test_no_nested_handler_invocation_on_cache_refresh ():
1919 """Verify that cache refresh doesn't use nested handler invocation.
20-
20+
2121 Issue #1298: Tool handlers can fail when cache refresh triggers
2222 nested handler invocation via self.request_handlers[ListToolsRequest](None),
2323 which disrupts async execution flow in streaming contexts.
24-
24+
2525 This test verifies the fix by detecting whether nested handler
2626 invocation occurs during cache refresh.
2727 """
2828 server = Server ("test-server" )
29-
29+
3030 # Track handler invocations
3131 handler_invocations = []
32-
32+
3333 @server .list_tools ()
3434 async def list_tools ():
3535 # Normal tool listing
3636 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-
37+ return [Tool (name = "test_tool" , description = "Test tool" , inputSchema = {"type" : "object" , "properties" : {}})]
38+
4539 @server .call_tool ()
4640 async def call_tool (name : str , arguments : dict [str , Any ]):
4741 # Simple tool implementation
4842 return [TextContent (type = "text" , text = "Tool result" )]
49-
43+
5044 # Intercept the ListToolsRequest handler to detect nested invocation
5145 original_handler = None
52-
46+
5347 def setup_handler_interceptor ():
5448 nonlocal original_handler
5549 original_handler = server .request_handlers .get (ListToolsRequest )
56-
57- async def interceptor (req ) :
50+
51+ async def interceptor (req : Any ) -> Any :
5852 # Track the invocation
5953 # req is None for nested invocations (the problematic pattern)
6054 # req is a proper request object for normal invocations
6155 if req is None :
6256 handler_invocations .append ("nested" )
6357 else :
6458 handler_invocations .append ("normal" )
65-
59+
6660 # Call the original handler
6761 if original_handler :
6862 return await original_handler (req )
6963 return None
70-
71- server .request_handlers [ListToolsRequest ] = interceptor
72-
64+
65+ server .request_handlers [ListToolsRequest ] = cast ( Any , interceptor )
66+
7367 # Set up the interceptor after decorators have run
7468 setup_handler_interceptor ()
75-
69+
7670 # Setup communication channels
77- from anyio .streams .memory import MemoryObjectReceiveStream , MemoryObjectSendStream
7871 from mcp .shared .message import SessionMessage
79-
72+
8073 server_to_client_send , server_to_client_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
8174 client_to_server_send , client_to_server_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
82-
75+
8376 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-
77+ await server .run (client_to_server_receive , server_to_client_send , server .create_initialization_options ())
78+
9079 async with anyio .create_task_group () as tg :
9180 tg .start_soon (run_server )
92-
81+
9382 async with ClientSession (server_to_client_receive , client_to_server_send ) as session :
9483 await session .initialize ()
95-
84+
9685 # Clear the cache to force a refresh on next tool call
9786 server ._tool_cache .clear ()
98-
87+
9988 # Make a tool call - this should trigger cache refresh
10089 result = await session .call_tool ("test_tool" , {})
101-
90+
10291 # Verify the tool call succeeded
10392 assert result is not None
10493 assert not result .isError
105- assert result .content [0 ].text == "Tool result"
106-
94+ content = result .content [0 ]
95+ assert isinstance (content , TextContent )
96+ assert content .text == "Tool result"
97+
10798 # Check if nested handler invocation occurred
10899 has_nested_invocation = "nested" in handler_invocations
109-
100+
110101 # The bug is present if nested handler invocation occurs
111102 assert not has_nested_invocation , (
112103 "Nested handler invocation detected during cache refresh. "
113104 "This pattern (calling request_handlers[ListToolsRequest](None)) "
114105 "can disrupt async execution in streaming contexts (issue #1298)."
115106 )
116-
107+
117108 tg .cancel_scope .cancel ()
118109
119110
120111@pytest .mark .anyio
121112async def test_concurrent_cache_refresh_safety ():
122113 """Verify that concurrent tool calls with cache refresh work correctly.
123-
114+
124115 Multiple concurrent tool calls that all trigger cache refresh should
125116 not cause issues or result in nested handler invocations.
126117 """
127118 server = Server ("test-server" )
128-
119+
129120 # Track concurrent handler invocations
130121 nested_invocations = 0
131-
122+
132123 @server .list_tools ()
133124 async def list_tools ():
134125 await anyio .sleep (0.01 ) # Simulate some async work
135126 return [
136- Tool (
137- name = f"tool_{ i } " ,
138- description = f"Tool { i } " ,
139- inputSchema = {"type" : "object" , "properties" : {}}
140- )
127+ Tool (name = f"tool_{ i } " , description = f"Tool { i } " , inputSchema = {"type" : "object" , "properties" : {}})
141128 for i in range (3 )
142129 ]
143-
130+
144131 @server .call_tool ()
145132 async def call_tool (name : str , arguments : dict [str , Any ]):
146133 await anyio .sleep (0.001 )
147134 return [TextContent (type = "text" , text = f"Result from { name } " )]
148-
135+
149136 # Intercept handler to detect nested invocations
150137 original_handler = server .request_handlers .get (ListToolsRequest )
151-
152- async def interceptor (req ) :
138+
139+ async def interceptor (req : Any ) -> Any :
153140 nonlocal nested_invocations
154141 if req is None :
155142 nested_invocations += 1
156143 if original_handler :
157144 return await original_handler (req )
158145 return None
159-
146+
160147 if original_handler :
161- server .request_handlers [ListToolsRequest ] = interceptor
162-
148+ server .request_handlers [ListToolsRequest ] = cast ( Any , interceptor )
149+
163150 # Setup communication
164- from anyio .streams .memory import MemoryObjectReceiveStream , MemoryObjectSendStream
165151 from mcp .shared .message import SessionMessage
166-
152+
167153 server_to_client_send , server_to_client_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
168154 client_to_server_send , client_to_server_receive = anyio .create_memory_object_stream [SessionMessage ](10 )
169-
155+
170156 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-
157+ await server .run (client_to_server_receive , server_to_client_send , server .create_initialization_options ())
158+
177159 async with anyio .create_task_group () as tg :
178160 tg .start_soon (run_server )
179-
161+
180162 async with ClientSession (server_to_client_receive , client_to_server_send ) as session :
181163 await session .initialize ()
182-
164+
183165 # Clear cache to force refresh
184166 server ._tool_cache .clear ()
185-
167+
186168 # Make concurrent tool calls
187169 import asyncio
170+
188171 results = await asyncio .gather (
189172 session .call_tool ("tool_0" , {}),
190173 session .call_tool ("tool_1" , {}),
191174 session .call_tool ("tool_2" , {}),
192- return_exceptions = True
175+ return_exceptions = True ,
193176 )
194-
177+
195178 # Verify all calls succeeded
196179 for i , result in enumerate (results ):
197180 assert not isinstance (result , Exception ), f"Tool { i } failed: { result } "
198181 assert not result .isError
199- assert f"tool_{ i } " in result .content [0 ].text
200-
182+ content = result .content [0 ]
183+ assert isinstance (content , TextContent )
184+ assert f"tool_{ i } " in content .text
185+
201186 # Verify no nested invocations occurred
202187 assert nested_invocations == 0 , (
203188 f"Detected { nested_invocations } nested handler invocations "
204189 "during concurrent cache refresh. This indicates the bug from "
205190 "issue #1298 is present."
206191 )
207-
208- tg .cancel_scope .cancel ()
192+
193+ tg .cancel_scope .cancel ()
0 commit comments