Skip to content

Commit 6891650

Browse files
committed
[verified] fix(client): release owned dispatcher exception hook
1 parent 6e30452 commit 6891650

2 files changed

Lines changed: 81 additions & 5 deletions

File tree

src/mcp/client/session.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ def __init__(
415415
self._negotiated_version: str | None = None
416416
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
417417
self._task_group: anyio.abc.TaskGroup | None = None
418+
self._owned_stream_exception_hook: Callable[[Exception], Any] | None = None
418419
# subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered)
419420
self._listen_routes: dict[RequestId, ListenRoute] = {}
420421
if dispatcher is not None:
@@ -424,11 +425,11 @@ def __init__(
424425
if isinstance(dispatcher, JSONRPCDispatcher) and dispatcher.on_stream_exception is None:
425426
# Route transport-level Exception items into message_handler — only
426427
# stream-backed dispatchers carry these; DirectDispatcher has none.
427-
# Don't clobber a caller-supplied hook.
428-
# TODO(L78): this leaves a bound-method ref on the dispatcher after the
429-
# session exits (memory pin) and a second wrap of the same dispatcher would
430-
# skip install. The Transport-as-Dispatcher rework (L77) removes this seam.
431-
dispatcher.on_stream_exception = self._on_stream_exception
428+
# Don't clobber a caller-supplied hook, and remember the exact bound
429+
# method object so shutdown can remove only our own installation.
430+
hook = self._on_stream_exception
431+
dispatcher.on_stream_exception = hook
432+
self._owned_stream_exception_hook = hook
432433
else:
433434
if read_stream is None or write_stream is None:
434435
raise ValueError("read_stream and write_stream are required when no dispatcher is given")
@@ -465,6 +466,7 @@ async def __aenter__(self) -> Self:
465466
await task_group.__aexit__(None, None, None)
466467
finally:
467468
self._close_binding_queues()
469+
self._remove_owned_stream_exception_hook()
468470
raise
469471
return self
470472

@@ -482,9 +484,18 @@ async def __aexit__(
482484
finally:
483485
self._close_binding_queues()
484486
self._settle_listen_routes_closed()
487+
self._remove_owned_stream_exception_hook()
485488
await resync_tracer()
486489
return result
487490

491+
def _remove_owned_stream_exception_hook(self) -> None:
492+
"""Remove the stream hook only if this session still owns the dispatcher slot."""
493+
hook = self._owned_stream_exception_hook
494+
if hook is not None and isinstance(self._dispatcher, JSONRPCDispatcher):
495+
if self._dispatcher.on_stream_exception is hook:
496+
self._dispatcher.on_stream_exception = None
497+
self._owned_stream_exception_hook = None
498+
488499
def _close_binding_queues(self) -> None:
489500
# Unclosed memory object streams warn at garbage collection; close is idempotent.
490501
for send, receive in self._binding_queues.values():

tests/client/test_session.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from mcp.server import Server, ServerRequestContext
4646
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
4747
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest
48+
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
4849
from mcp.shared.message import SessionMessage
4950
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY
5051
from mcp.shared.transport_context import TransportContext
@@ -1177,6 +1178,70 @@ async def server_on_notify(
11771178
assert notified == ["notifications/roots/list_changed"]
11781179

11791180

1181+
@pytest.mark.anyio
1182+
async def test_dispatcher_keyword_removes_its_stream_exception_hook_on_exit():
1183+
"""An injected stream dispatcher must not retain the exited session through its hook."""
1184+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
1185+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage](1)
1186+
try:
1187+
dispatcher = JSONRPCDispatcher(s2c_recv, c2s_send)
1188+
session = ClientSession(dispatcher=dispatcher)
1189+
1190+
assert dispatcher.on_stream_exception is not None
1191+
1192+
async with session:
1193+
pass
1194+
1195+
assert dispatcher.on_stream_exception is None
1196+
finally:
1197+
s2c_send.close()
1198+
s2c_recv.close()
1199+
c2s_send.close()
1200+
c2s_recv.close()
1201+
1202+
1203+
@pytest.mark.anyio
1204+
async def test_dispatcher_keyword_reinstalls_stream_exception_hook_for_reused_dispatcher():
1205+
"""A dispatcher can be wrapped by another session after the first session exits."""
1206+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
1207+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage](1)
1208+
try:
1209+
dispatcher = JSONRPCDispatcher(s2c_recv, c2s_send)
1210+
async with ClientSession(dispatcher=dispatcher):
1211+
pass
1212+
assert dispatcher.on_stream_exception is None
1213+
1214+
async with ClientSession(dispatcher=dispatcher):
1215+
assert dispatcher.on_stream_exception is not None
1216+
assert dispatcher.on_stream_exception is None
1217+
finally:
1218+
s2c_send.close()
1219+
s2c_recv.close()
1220+
c2s_send.close()
1221+
c2s_recv.close()
1222+
1223+
1224+
@pytest.mark.anyio
1225+
async def test_dispatcher_keyword_preserves_caller_stream_exception_hook_on_exit():
1226+
"""A hook supplied by the dispatcher owner remains installed after session shutdown."""
1227+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
1228+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage](1)
1229+
try:
1230+
async def caller_hook(_exc: Exception) -> None:
1231+
pass
1232+
1233+
dispatcher = JSONRPCDispatcher(s2c_recv, c2s_send, on_stream_exception=caller_hook)
1234+
async with ClientSession(dispatcher=dispatcher):
1235+
pass
1236+
1237+
assert dispatcher.on_stream_exception is caller_hook
1238+
finally:
1239+
s2c_send.close()
1240+
s2c_recv.close()
1241+
c2s_send.close()
1242+
c2s_recv.close()
1243+
1244+
11801245
@pytest.mark.anyio
11811246
async def test_direct_dispatch_roots_list_reaches_callback_with_synthesized_request_id():
11821247
"""A server-initiated roots/list over dispatcher= reaches the registered callback and round-trips

0 commit comments

Comments
 (0)