Skip to content

Commit 1dca6f2

Browse files
committed
Cover malformed MCP response timeout with e2e proof
Constraint: Reproduce malformed JSON-RPC response before changing SDK behavior. Rejected: Unit-only coverage | it did not exercise the MCP transport/context-manager path. Confidence: high Scope-risk: narrow Directive: Keep malformed MCP response handling bounded at the SDK MCP boundary; services must still return valid JSON-RPC errors. Tested: uv run pytest tests/e2e/test_mcp_malformed_response.py -q; uv run pytest tests/unittests/tool/test_mcp.py -q; uv run pytest tests/unittests/tool -q; uv run ruff check agentrun/tool/api/mcp.py tests/unittests/tool/test_mcp.py tests/e2e/test_mcp_malformed_response.py; git diff --check Change-Id: Icde49bbfd79f29eb64acdab904f1f5df8df47bcd Not-tested: Full e2e suite against remote AgentRun services.
1 parent b9c911d commit 1dca6f2

2 files changed

Lines changed: 115 additions & 0 deletions

File tree

agentrun/tool/api/mcp.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,28 @@ async def _wait_for_mcp_request(
170170
f" {self.endpoint}"
171171
) from exc
172172

173+
def _find_mcp_timeout_error(
174+
self, exc: BaseException
175+
) -> Optional[TimeoutError]:
176+
if isinstance(exc, TimeoutError) and str(exc).startswith("MCP "):
177+
return exc
178+
179+
nested_exceptions = getattr(exc, "exceptions", None)
180+
if not nested_exceptions:
181+
return None
182+
183+
for nested_exc in nested_exceptions:
184+
timeout_error = self._find_mcp_timeout_error(nested_exc)
185+
if timeout_error is not None:
186+
return timeout_error
187+
188+
return None
189+
190+
def _raise_mcp_timeout_if_present(self, exc: BaseException) -> None:
191+
timeout_error = self._find_mcp_timeout_error(exc)
192+
if timeout_error is not None:
193+
raise timeout_error
194+
173195
def _build_ram_auth(self, url: str) -> tuple:
174196
"""当目标是 agentrun-data 域名时,改写 URL 并返回 httpx Auth handler。
175197
@@ -270,6 +292,9 @@ async def list_tools_async(self) -> List[ToolInfo]:
270292
"mcp package is not installed. Install it with: pip install mcp"
271293
)
272294
return []
295+
except Exception as exc:
296+
self._raise_mcp_timeout_if_present(exc)
297+
raise
273298

274299
def list_tools(self) -> List[ToolInfo]:
275300
"""同步获取工具列表 / Get tool list synchronously
@@ -347,6 +372,9 @@ async def call_tool_async(
347372
"mcp package is required for MCP tool calls. "
348373
"Install it with: pip install mcp"
349374
)
375+
except Exception as exc:
376+
self._raise_mcp_timeout_if_present(exc)
377+
raise
350378

351379
def call_tool(
352380
self,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""E2E regression tests for malformed MCP streamable-http responses."""
2+
3+
import asyncio
4+
import socket
5+
import threading
6+
import time
7+
8+
from fastapi import FastAPI, Request
9+
from fastapi.responses import JSONResponse
10+
import httpx
11+
import pytest
12+
import uvicorn
13+
14+
from agentrun.tool.api.mcp import ToolMCPSession
15+
from agentrun.utils.config import Config
16+
17+
18+
def _find_free_port() -> int:
19+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
20+
sock.bind(("127.0.0.1", 0))
21+
return sock.getsockname()[1]
22+
23+
24+
def _build_malformed_mcp_app() -> FastAPI:
25+
app = FastAPI()
26+
27+
@app.get("/health")
28+
async def health():
29+
return {"ok": True}
30+
31+
@app.post("/mcp")
32+
async def mcp_endpoint(request: Request):
33+
payload = await request.json()
34+
return JSONResponse(
35+
{
36+
"jsonrpc": "2.0",
37+
"id": payload.get("id"),
38+
"error": {
39+
"code": -32000,
40+
"message": None,
41+
},
42+
}
43+
)
44+
45+
return app
46+
47+
48+
@pytest.fixture
49+
def malformed_mcp_server():
50+
app = _build_malformed_mcp_app()
51+
port = _find_free_port()
52+
config = uvicorn.Config(
53+
app, host="127.0.0.1", port=port, log_level="warning"
54+
)
55+
server = uvicorn.Server(config)
56+
57+
thread = threading.Thread(target=server.run, daemon=True)
58+
thread.start()
59+
60+
base_url = f"http://127.0.0.1:{port}"
61+
for _ in range(50):
62+
try:
63+
httpx.get(f"{base_url}/health", timeout=0.2)
64+
break
65+
except Exception:
66+
time.sleep(0.1)
67+
else:
68+
raise RuntimeError("malformed MCP server did not start")
69+
70+
yield f"{base_url}/mcp"
71+
72+
server.should_exit = True
73+
thread.join(timeout=5)
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_streamable_mcp_malformed_initialize_error_fails_fast(
78+
malformed_mcp_server,
79+
):
80+
session = ToolMCPSession(
81+
endpoint=malformed_mcp_server,
82+
session_affinity="MCP_STREAMABLE",
83+
config=Config(timeout=0.05),
84+
)
85+
86+
with pytest.raises(TimeoutError, match="MCP initialize timed out"):
87+
await asyncio.wait_for(session.list_tools_async(), timeout=1)

0 commit comments

Comments
 (0)