From 1ae1dd8b2c63fdd3b950553c56d9f5e56f3d23ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B6rje?= Date: Sat, 18 Jul 2026 00:07:11 +0200 Subject: [PATCH] fix(mcp): harden nested interruption detection --- tests/tools/test_mcp_tool_session_expired.py | 33 ++++++++++++++++++++ tools/mcp_tool.py | 18 +++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_mcp_tool_session_expired.py b/tests/tools/test_mcp_tool_session_expired.py index c794236e04f2e..f6aa9ee590bc6 100644 --- a/tests/tools/test_mcp_tool_session_expired.py +++ b/tests/tools/test_mcp_tool_session_expired.py @@ -121,6 +121,39 @@ def test_is_session_expired_rejects_mixed_group_with_user_interruption(): assert _is_session_expired_error(exc) is False +def test_exception_tree_finds_interruption_beyond_recursion_limit(): + """Arbitrarily deep wrapper trees must not overflow Python's call stack.""" + import sys + + from tools.mcp_tool import _exception_tree_contains_interruption + + class NestedException(Exception): + exceptions: tuple[BaseException, ...] + + exc = InterruptedError("cancel") + for _ in range(sys.getrecursionlimit() + 100): + wrapper = NestedException("wrapped") + wrapper.exceptions = (exc,) + exc = wrapper + + assert _exception_tree_contains_interruption(exc) is True + + +def test_exception_tree_handles_cyclic_exceptions_graph(): + """Malformed exception graphs may contain cycles and must terminate safely.""" + from tools.mcp_tool import _exception_tree_contains_interruption + + class CyclicException(Exception): + exceptions: tuple[BaseException, ...] + + first = CyclicException("first") + second = CyclicException("second") + first.exceptions = (second,) + second.exceptions = (first,) + + assert _exception_tree_contains_interruption(first) is False + + def test_is_session_expired_rejects_empty_message(): """Bare exceptions with no message shouldn't match.""" from tools.mcp_tool import _is_session_expired_error diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index aca2651597d48..c2b9503a84e2c 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3902,12 +3902,18 @@ _SESSION_EXPIRED_MARKERS: tuple = ( def _exception_tree_contains_interruption(exc: BaseException) -> bool: """Return whether ``exc`` or any nested exception is user cancellation.""" - if isinstance(exc, InterruptedError): - return True - return any( - _exception_tree_contains_interruption(child) - for child in getattr(exc, "exceptions", ()) - ) + stack = [exc] + seen: set[int] = set() + while stack: + current = stack.pop() + identity = id(current) + if identity in seen: + continue + seen.add(identity) + if isinstance(current, InterruptedError): + return True + stack.extend(getattr(current, "exceptions", ())) + return False def _is_session_expired_error(exc: BaseException) -> bool: