diff --git a/tests/tools/test_mcp_stdio_children_dead.py b/tests/tools/test_mcp_stdio_children_dead.py index 09398d59586f..23ec4a80775e 100644 --- a/tests/tools/test_mcp_stdio_children_dead.py +++ b/tests/tools/test_mcp_stdio_children_dead.py @@ -92,3 +92,36 @@ async def _run(): ) asyncio.run(_run()) + + +def test_watch_ok_probe_does_not_create_unawaited_coroutine(): + """The fast-fail gate must inspect the watcher, not call it (#96044). + + The old probe — inspect.isawaitable(_watch_children()) — created a + fresh coroutine per stdio tool call and never awaited it, emitting + 'coroutine ... was never awaited' RuntimeWarnings under -W error and + churning the GC. Pin that the shipped source no longer calls the + watcher during the probe. + """ + import inspect as _inspect + + import tools.mcp_tool as mcp_mod + + src = _inspect.getsource(mcp_mod) + assert "isawaitable(_watch_children())" not in src + assert "iscoroutinefunction(_watch_children)" in src + + +def test_watch_ok_semantics_mock_vs_real(): + """MagicMock watchers stay on the plain-await path; real async defs + (and AsyncMock) qualify for the fast-fail race — same split the old + isawaitable(call) probe produced, without the coroutine leak.""" + import inspect as _inspect + from unittest.mock import AsyncMock, MagicMock + + async def _real_watcher(): # what the real method looks like + pass + + assert _inspect.iscoroutinefunction(_real_watcher) is True + assert _inspect.iscoroutinefunction(AsyncMock()) is True + assert _inspect.iscoroutinefunction(MagicMock()) is False diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 658024e78b84..97f8c6705623 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -6174,7 +6174,7 @@ async def _call(): _watch_children = getattr(server, "_watch_stdio_children", None) _watch_ok = ( _watch_children is not None - and inspect.isawaitable(_watch_children()) + and inspect.iscoroutinefunction(_watch_children) and asyncio.iscoroutine(_call_coro) ) if not _watch_ok: