1010import mcp_types as types
1111import pytest
1212from mcp_types import SubscriptionFilter
13+ from trio .testing import MockClock
1314
1415import mcp .client .subscriptions as subscriptions_module
1516from mcp import Client , MCPError
3435)
3536from mcp .shared .direct_dispatcher import create_direct_dispatcher_pair
3637from mcp .shared .dispatcher import CallOptions
38+ from mcp .shared .jsonrpc_dispatcher import JSONRPCDispatcher
39+ from mcp .shared .message import SessionMessage
3740
3841pytestmark = pytest .mark .anyio
3942
4043
44+ @pytest .fixture (autouse = True )
45+ def _module_runner_lease () -> None :
46+ """Opt out of the shared runner because the cleanup timeout test parametrizes `anyio_backend`."""
47+
48+
4149def _bus_server (bus : InMemorySubscriptionBus , * , max_subscriptions : int | None = None ) -> Server [Any ]:
4250 """A lowlevel server whose only feature is serving listen streams from `bus`."""
4351 handler = (
@@ -214,6 +222,7 @@ async def cancelling_listen(
214222 await anext (sub )
215223
216224
225+ @pytest .mark .parametrize ("anyio_backend" , ["asyncio" , "trio" ])
217226async def test_exiting_the_context_frees_the_server_slot ():
218227 """Leaving the block ends the subscription server-side: a one-slot handler admits a second listen."""
219228 bus = InMemorySubscriptionBus ()
@@ -226,6 +235,162 @@ async def test_exiting_the_context_frees_the_server_slot():
226235 assert second .subscription_id != first .subscription_id
227236
228237
238+ @pytest .mark .parametrize ("anyio_backend" , ["asyncio" , "trio" ])
239+ async def test_a_cancelled_task_can_close_a_subscription_opened_by_another_task () -> None :
240+ """SDK-defined: cross-task exit joins direct handler cleanup even when the closing task is cancelled."""
241+ handler = ListenHandler (InMemorySubscriptionBus ())
242+ cleanup_started = anyio .Event ()
243+ release_cleanup = anyio .Event ()
244+ cleanup_finished = anyio .Event ()
245+ closed = anyio .Event ()
246+
247+ async def slow_cleanup (
248+ ctx : ServerRequestContext , params : types .SubscriptionsListenRequestParams
249+ ) -> types .SubscriptionsListenResult :
250+ assert params .notifications .tools_list_changed is True
251+ try :
252+ return await handler (ctx , params )
253+ finally :
254+ with anyio .fail_after (5 , shield = True ):
255+ cleanup_started .set ()
256+ await release_cleanup .wait ()
257+ cleanup_finished .set ()
258+
259+ server = Server ("subs" , on_subscriptions_listen = slow_cleanup )
260+ with anyio .fail_after (5 ):
261+ async with Client (server ) as client , anyio .create_task_group () as tg :
262+ subscription = client .listen (tools_list_changed = True )
263+ await subscription .__aenter__ ()
264+
265+ async def close () -> None :
266+ with anyio .CancelScope () as scope :
267+ scope .cancel ()
268+ try :
269+ await anyio .Event ().wait ()
270+ except anyio .get_cancelled_exc_class () as exc :
271+ await subscription .__aexit__ (type (exc ), exc , exc .__traceback__ )
272+ assert cleanup_finished .is_set ()
273+ closed .set ()
274+ raise
275+
276+ tg .start_soon (close )
277+ try :
278+ await cleanup_started .wait ()
279+ await anyio .wait_all_tasks_blocked ()
280+ assert not closed .is_set ()
281+ finally :
282+ release_cleanup .set ()
283+ await closed .wait ()
284+
285+
286+ @pytest .mark .parametrize (
287+ "anyio_backend" ,
288+ [pytest .param (("trio" , {"clock" : MockClock (autojump_threshold = 0 )}), id = "trio-mockclock" )],
289+ )
290+ async def test_exiting_a_subscription_bounds_uncooperative_direct_handler_cleanup () -> None :
291+ """SDK-defined: exit stops waiting after five seconds if a direct handler shields its cleanup."""
292+ handler = ListenHandler (InMemorySubscriptionBus ())
293+ cleanup_started = anyio .Event ()
294+ release_cleanup = anyio .Event ()
295+ cleanup_finished = anyio .Event ()
296+
297+ async def shielded_cleanup (
298+ ctx : ServerRequestContext , params : types .SubscriptionsListenRequestParams
299+ ) -> types .SubscriptionsListenResult :
300+ assert params .notifications .tools_list_changed is True
301+ try :
302+ return await handler (ctx , params )
303+ finally :
304+ # The watchdog must outlast the SDK's five-second cleanup cap.
305+ with anyio .fail_after (10 , shield = True ):
306+ cleanup_started .set ()
307+ await release_cleanup .wait ()
308+ cleanup_finished .set ()
309+
310+ server = Server ("subs" , on_subscriptions_listen = shielded_cleanup )
311+ async with Client (server ) as client :
312+ subscription = client .listen (tools_list_changed = True )
313+ with anyio .fail_after (5 ):
314+ sub = await subscription .__aenter__ ()
315+ try :
316+ started = anyio .current_time ()
317+ await subscription .__aexit__ (None , None , None )
318+ assert anyio .current_time () - started == 5 # MockClock time, never wall-clock time.
319+ assert cleanup_started .is_set ()
320+ assert not cleanup_finished .is_set ()
321+ with pytest .raises (StopAsyncIteration ):
322+ await anext (sub )
323+ finally :
324+ release_cleanup .set ()
325+ with anyio .fail_after (5 ):
326+ await cleanup_finished .wait ()
327+
328+
329+ @pytest .mark .parametrize (
330+ "anyio_backend" ,
331+ [pytest .param (("trio" , {"clock" : MockClock (autojump_threshold = 0 )}), id = "trio-mockclock" )],
332+ )
333+ @pytest .mark .parametrize ("cancelled" , [False , True ], ids = ["normal-exit" , "cancelled-exit" ])
334+ async def test_sequential_remote_subscription_exits_do_not_wait_for_courtesy_writes (
335+ monkeypatch : pytest .MonkeyPatch , cancelled : bool
336+ ) -> None :
337+ """SDK-defined: remote exits leave courtesy writes to session-owned drivers, even when cancelled.
338+
339+ Block the public stream `send` boundary; typed server handlers cannot wedge a client's transport write.
340+ """
341+ server = Server ("subs" , on_subscriptions_listen = ListenHandler (InMemorySubscriptionBus ()))
342+ client_write , server_read = anyio .create_memory_object_stream [SessionMessage | Exception ]()
343+ server_write , client_read = anyio .create_memory_object_stream [SessionMessage | Exception ]()
344+ release_writes = anyio .Event ()
345+ attempted : list [types .RequestId ] = []
346+ delivered : list [types .RequestId ] = []
347+ send = client_write .send
348+
349+ async def block_courtesy_write (item : SessionMessage | Exception ) -> None :
350+ assert isinstance (item , SessionMessage )
351+ message = item .message
352+ if isinstance (message , types .JSONRPCNotification ) and message .method == "notifications/cancelled" :
353+ assert message .params is not None
354+ request_id = message .params ["requestId" ]
355+ attempted .append (request_id )
356+ with anyio .fail_after (5 ):
357+ await release_writes .wait ()
358+ await send (item )
359+ delivered .append (request_id )
360+ else :
361+ await send (item )
362+
363+ monkeypatch .setattr (client_write , "send" , block_courtesy_write )
364+ dispatcher = JSONRPCDispatcher (client_read , client_write )
365+ with anyio .fail_after (5 ):
366+ async with client_write , server_read , server_write , client_read , anyio .create_task_group () as tg :
367+ tg .start_soon (server .run , server_read , server_write , server .create_initialization_options ())
368+ async with ClientSession (dispatcher = dispatcher ) as session :
369+ await session .discover ()
370+ subscription_ids : list [types .RequestId ] = []
371+ started = anyio .current_time ()
372+ try :
373+ for _ in range (2 ):
374+ subscription = listen (session , tools_list_changed = True )
375+ sub = await subscription .__aenter__ ()
376+ subscription_ids .append (sub .subscription_id )
377+ with anyio .CancelScope () as scope :
378+ if cancelled :
379+ scope .cancel ()
380+ await subscription .__aexit__ (None , None , None )
381+ assert anyio .current_time () == started # MockClock time, never wall-clock time.
382+ with pytest .raises (StopAsyncIteration ):
383+ await anext (sub )
384+ await anyio .wait_all_tasks_blocked ()
385+ assert attempted == subscription_ids
386+ assert delivered == []
387+ finally :
388+ release_writes .set ()
389+ await anyio .wait_all_tasks_blocked ()
390+ assert set (delivered ) == set (subscription_ids )
391+ tg .cancel_scope .cancel ()
392+
393+
229394async def test_concurrent_subscriptions_demux_independently ():
230395 """Two open subscriptions each receive only their own filter's events."""
231396 bus = InMemorySubscriptionBus ()
@@ -599,6 +764,8 @@ async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_
599764 with anyio .fail_after (5 ):
600765 async with uncached_client .listen (tools_list_changed = True ) as sub : # pragma: no branch
601766 assert sub ._on_event is None # pyright: ignore[reportPrivateUsage]
767+ await bus .publish (ToolsListChanged ())
768+ assert await anext (sub ) == ToolsListChanged ()
602769
603770
604771async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults (
0 commit comments