Skip to content

Commit b9c911d

Browse files
committed
Prevent MCP tool metadata hangs on malformed responses
Constraint: MCP Python client can log malformed JSON-RPC errors without waking pending initialize/list_tools awaits. Rejected: Template-side timeout only | leaves SDK callers exposed to the same hang. Confidence: high Scope-risk: narrow Directive: Keep MCP metadata operations bounded so agent creation cannot wait indefinitely on malformed server responses. Tested: uv run ruff check agentrun/tool/api/mcp.py tests/unittests/tool/test_mcp.py; uv run pytest tests/unittests/tool/test_mcp.py -q; uv run pytest tests/unittests/tool -q; git diff --check Not-tested: live MCP server returning malformed JSON-RPC error Closes: coop#82638110 Change-Id: I20569d10af7ba44c140ab19e446d7fc35870f7ec
1 parent c983c34 commit b9c911d

2 files changed

Lines changed: 128 additions & 15 deletions

File tree

agentrun/tool/api/mcp.py

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010

1111
import httpx
1212

13-
from agentrun.tool.model import ToolInfo, ToolSchema
13+
from agentrun.tool.model import ToolInfo
1414
from agentrun.utils.config import Config
1515
from agentrun.utils.log import logger
16+
from agentrun.utils.ram_signature import get_agentrun_signed_headers
17+
18+
_MCP_METADATA_TIMEOUT_SECONDS = 30.0
1619

1720

1821
def _get_or_create_event_loop() -> asyncio.AbstractEventLoop:
@@ -30,9 +33,6 @@ def _get_or_create_event_loop() -> asyncio.AbstractEventLoop:
3033
return loop
3134

3235

33-
from agentrun.utils.ram_signature import get_agentrun_signed_headers
34-
35-
3636
class _AgentrunRamAuth(httpx.Auth):
3737
"""httpx Auth handler:为每次请求动态生成 RAM 签名。
3838
@@ -144,6 +144,32 @@ def is_streamable(self) -> bool:
144144
"""是否使用 Streamable HTTP 传输 / Whether to use Streamable HTTP transport"""
145145
return self.session_affinity == "MCP_STREAMABLE"
146146

147+
def _metadata_timeout_seconds(self) -> float:
148+
timeout = self.config.get_timeout()
149+
if timeout and timeout > 0:
150+
return min(float(timeout), _MCP_METADATA_TIMEOUT_SECONDS)
151+
return _MCP_METADATA_TIMEOUT_SECONDS
152+
153+
def _invoke_timeout_seconds(self) -> float:
154+
timeout = self.config.get_timeout()
155+
if timeout and timeout > 0:
156+
return float(timeout)
157+
return 600.0
158+
159+
async def _wait_for_mcp_request(
160+
self,
161+
awaitable: Any,
162+
operation: str,
163+
timeout: float,
164+
) -> Any:
165+
try:
166+
return await asyncio.wait_for(awaitable, timeout=timeout)
167+
except asyncio.TimeoutError as exc:
168+
raise TimeoutError(
169+
f"MCP {operation} timed out after {timeout:g}s for endpoint"
170+
f" {self.endpoint}"
171+
) from exc
172+
147173
def _build_ram_auth(self, url: str) -> tuple:
148174
"""当目标是 agentrun-data 域名时,改写 URL 并返回 httpx Auth handler。
149175
@@ -199,8 +225,17 @@ async def list_tools_async(self) -> List[ToolInfo]:
199225
async with ClientSession(
200226
read_stream, write_stream
201227
) as session:
202-
await session.initialize()
203-
result = await session.list_tools()
228+
metadata_timeout = self._metadata_timeout_seconds()
229+
await self._wait_for_mcp_request(
230+
session.initialize(),
231+
"initialize",
232+
metadata_timeout,
233+
)
234+
result = await self._wait_for_mcp_request(
235+
session.list_tools(),
236+
"list_tools",
237+
metadata_timeout,
238+
)
204239
return [
205240
ToolInfo.from_mcp_tool(tool)
206241
for tool in result.tools
@@ -215,8 +250,17 @@ async def list_tools_async(self) -> List[ToolInfo]:
215250
async with ClientSession(
216251
read_stream, write_stream
217252
) as session:
218-
await session.initialize()
219-
result = await session.list_tools()
253+
metadata_timeout = self._metadata_timeout_seconds()
254+
await self._wait_for_mcp_request(
255+
session.initialize(),
256+
"initialize",
257+
metadata_timeout,
258+
)
259+
result = await self._wait_for_mcp_request(
260+
session.list_tools(),
261+
"list_tools",
262+
metadata_timeout,
263+
)
220264
return [
221265
ToolInfo.from_mcp_tool(tool)
222266
for tool in result.tools
@@ -266,9 +310,15 @@ async def call_tool_async(
266310
async with ClientSession(
267311
read_stream, write_stream
268312
) as session:
269-
await session.initialize()
270-
result = await session.call_tool(
271-
name, arguments=arguments or {}
313+
await self._wait_for_mcp_request(
314+
session.initialize(),
315+
"initialize",
316+
self._metadata_timeout_seconds(),
317+
)
318+
result = await self._wait_for_mcp_request(
319+
session.call_tool(name, arguments=arguments or {}),
320+
f"call_tool {name}",
321+
self._invoke_timeout_seconds(),
272322
)
273323
return result
274324
else:
@@ -281,9 +331,15 @@ async def call_tool_async(
281331
async with ClientSession(
282332
read_stream, write_stream
283333
) as session:
284-
await session.initialize()
285-
result = await session.call_tool(
286-
name, arguments=arguments or {}
334+
await self._wait_for_mcp_request(
335+
session.initialize(),
336+
"initialize",
337+
self._metadata_timeout_seconds(),
338+
)
339+
result = await self._wait_for_mcp_request(
340+
session.call_tool(name, arguments=arguments or {}),
341+
f"call_tool {name}",
342+
self._invoke_timeout_seconds(),
287343
)
288344
return result
289345
except ImportError:

tests/unittests/tool/test_mcp.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
Tests MCP protocol interaction functionality of ToolMCPSession.
55
"""
66

7+
import asyncio
78
import sys
8-
from unittest.mock import AsyncMock, MagicMock, Mock, patch
9+
from unittest.mock import AsyncMock, MagicMock, patch
910

1011
import pytest
1112

1213
from agentrun.tool.api.mcp import ToolMCPSession
1314
from agentrun.tool.model import ToolInfo
15+
from agentrun.utils.config import Config
1416

1517

1618
class TestToolMCPSessionInit:
@@ -186,6 +188,36 @@ def mock_import(name, *args, **kwargs):
186188
sys.modules.update(saved_modules)
187189
assert tools == []
188190

191+
@pytest.mark.asyncio
192+
async def test_list_tools_async_initialize_timeout(self):
193+
"""测试 initialize 无响应时不会无限等待"""
194+
195+
async def never_return():
196+
await asyncio.Event().wait()
197+
198+
mock_session = AsyncMock()
199+
mock_session.initialize = never_return
200+
mock_session.list_tools = AsyncMock()
201+
202+
mock_modules = _setup_mock_mcp_modules(mock_session)
203+
204+
with patch.dict(sys.modules, mock_modules):
205+
with patch(
206+
"agentrun.tool.api.mcp._MCP_METADATA_TIMEOUT_SECONDS",
207+
0.01,
208+
):
209+
session = ToolMCPSession(
210+
endpoint="http://example.com/mcp",
211+
session_affinity="MCP_STREAMABLE",
212+
)
213+
214+
with pytest.raises(
215+
TimeoutError, match="MCP initialize timed out"
216+
):
217+
await session.list_tools_async()
218+
219+
mock_session.list_tools.assert_not_called()
220+
189221

190222
class TestToolMCPSessionListTools:
191223
"""测试 list_tools 同步方法"""
@@ -258,6 +290,31 @@ async def test_call_tool_async_sse_mode(self):
258290

259291
assert result == mock_call_result
260292

293+
@pytest.mark.asyncio
294+
async def test_call_tool_async_timeout(self):
295+
"""测试工具调用无响应时会按 Config.timeout 退出"""
296+
297+
async def never_return(*args, **kwargs):
298+
await asyncio.Event().wait()
299+
300+
mock_session = AsyncMock()
301+
mock_session.initialize = AsyncMock()
302+
mock_session.call_tool = never_return
303+
304+
mock_modules = _setup_mock_mcp_modules(mock_session)
305+
306+
with patch.dict(sys.modules, mock_modules):
307+
session = ToolMCPSession(
308+
endpoint="http://example.com/mcp",
309+
session_affinity="MCP_STREAMABLE",
310+
config=Config(timeout=0.01),
311+
)
312+
313+
with pytest.raises(
314+
TimeoutError, match="MCP call_tool test_tool timed out"
315+
):
316+
await session.call_tool_async("test_tool", {"key": "val"})
317+
261318
@pytest.mark.asyncio
262319
async def test_call_tool_async_import_error(self):
263320
"""测试 mcp 未安装时抛出 ImportError"""

0 commit comments

Comments
 (0)