11"""Tests for MCPToolResolver native (non-AMP) resolution paths."""
22
3+ import threading
34from unittest .mock import AsyncMock , MagicMock , patch
45
56import pytest
@@ -99,26 +100,63 @@ def test_unmatched_runtime_error_is_wrapped_not_swallowed(
99100 resolver ._resolve_native (http_config )
100101
101102
103+ class _ThreadAffinityFakeClient :
104+ """Fake MCP client that raises when awaited from the main thread.
105+
106+ Simulates the thread-affinity constraint of real asyncio objects:
107+ async methods must run in a worker thread, not on the main thread
108+ where an event loop is already running.
109+ """
110+
111+ def __init__ (self , * args , ** kwargs ):
112+ self ._main_tid = threading .current_thread ().ident
113+ self .connected = False
114+ self ._executor_tid = None
115+
116+ def _check_affinity (self ):
117+ if (
118+ threading .current_thread ().ident == self ._main_tid
119+ and _loop_is_running ()
120+ ):
121+ raise RuntimeError (
122+ "Event loop affinity: async operations must run in a worker thread"
123+ )
124+
125+ async def connect (self ):
126+ self ._check_affinity ()
127+ self ._executor_tid = threading .current_thread ().ident
128+ self .connected = True
129+
130+ async def disconnect (self ):
131+ self ._check_affinity ()
132+ self .connected = False
133+
134+ async def list_tools (self ):
135+ self ._check_affinity ()
136+ return [{"name" : "test_tool" , "description" : "A test tool" , "inputSchema" : {}}]
137+
138+
139+ def _loop_is_running ():
140+ try :
141+ import asyncio
142+ asyncio .get_running_loop ()
143+ return True
144+ except RuntimeError :
145+ return False
146+
147+
102148class TestResolveNativeAsyncioThreadAffinity :
103- @patch ("crewai.mcp.tool_resolver.MCPClient" )
149+ @patch ("crewai.mcp.tool_resolver.MCPClient" , _ThreadAffinityFakeClient )
104150 def test_resolve_native_from_running_loop_uses_executor_path (
105- self , mock_client_class , resolver , http_config
151+ self , resolver , http_config
106152 ):
107153 """Test that _resolve_native works when called from a running event loop.
108154
109- When an event loop is already running, _resolve_native uses
110- executor.submit() with a lambda to create the coroutine inside
111- the worker thread, avoiding the thread affinity error.
155+ Uses a thread-aware fake that raises a thread-affinity error unless
156+ async methods execute in a worker thread (i.e. via the executor path).
112157 """
113158 import asyncio
114159
115- mock_client = AsyncMock ()
116- mock_client .list_tools = AsyncMock (return_value = [])
117- mock_client .connected = False
118- mock_client .connect = AsyncMock ()
119- mock_client .disconnect = AsyncMock ()
120- mock_client_class .return_value = mock_client
121-
122160 mock_log = MagicMock ()
123161 resolver ._logger = MagicMock (log = mock_log )
124162
@@ -127,5 +165,6 @@ async def _call_resolve_native():
127165
128166 tools , clients = asyncio .run (_call_resolve_native ())
129167
130- assert tools == []
168+ assert len (tools ) == 1
169+ assert tools [0 ].name == "test_tool"
131170 assert clients == []
0 commit comments