|
6 | 6 | - Utility functions for name sanitization and schema conversion |
7 | 7 | """ |
8 | 8 |
|
| 9 | +import asyncio |
9 | 10 | import re |
10 | 11 | import shutil |
11 | 12 | import sys |
| 13 | +from contextlib import suppress |
12 | 14 | from unittest.mock import AsyncMock, MagicMock, patch |
13 | 15 |
|
| 16 | +import httpx |
14 | 17 | import pytest |
15 | 18 | from lfx.base.mcp import util |
16 | 19 | from lfx.base.mcp.util import ( |
17 | 20 | MCPSessionManager, |
18 | 21 | MCPSseClient, |
19 | 22 | MCPStdioClient, |
20 | 23 | MCPStreamableHttpClient, |
| 24 | + _is_transient_streamable_http_error, |
21 | 25 | _process_headers, |
| 26 | + _should_attempt_sse_after_streamable_failure, |
22 | 27 | update_tools, |
23 | 28 | validate_headers, |
24 | 29 | ) |
@@ -1981,16 +1986,17 @@ async def mock_get_session_side_effect(): |
1981 | 1986 | patch.object(sse_client, "_get_or_create_session", side_effect=mock_get_session_side_effect), |
1982 | 1987 | patch.object(sse_client, "_get_session_manager") as mock_get_manager, |
1983 | 1988 | ): |
1984 | | - mock_manager = AsyncMock() |
| 1989 | + mock_manager = MagicMock() |
| 1990 | + mock_manager.invalidate_server_key = AsyncMock() |
| 1991 | + mock_manager._get_server_key = MagicMock(return_value="streamable_http_testkey") |
1985 | 1992 | mock_get_manager.return_value = mock_manager |
1986 | 1993 |
|
1987 | 1994 | result = await sse_client.run_tool("test_tool", {"param": "value"}) |
1988 | 1995 |
|
1989 | 1996 | # Should have retried and succeeded on second attempt |
1990 | 1997 | assert call_count == 2 |
1991 | 1998 | assert result is not None |
1992 | | - # Should have cleaned up the failed session |
1993 | | - mock_manager._cleanup_session.assert_called_once_with("test_context") |
| 1999 | + mock_manager.invalidate_server_key.assert_called_once_with("streamable_http_testkey") |
1994 | 2000 |
|
1995 | 2001 |
|
1996 | 2002 | class TestMCPStructuredTool: |
@@ -3332,3 +3338,128 @@ async def on_tool_end(self, output, *, run_id, parent_run_id=None, **kwargs): |
3332 | 3338 | assert handler.outputs[0].name == "get_image" |
3333 | 3339 | assert handler.outputs[0].artifact is raw |
3334 | 3340 | assert result.name == "get_image" |
| 3341 | + |
| 3342 | + |
| 3343 | +class TestStreamableHttpTransportPolicy: |
| 3344 | + """Mocked streamable HTTP vs SSE: reconnect policy and fallback classification.""" |
| 3345 | + |
| 3346 | + def _connection_params(self): |
| 3347 | + return { |
| 3348 | + "url": "http://test-mcp.example/mcp", |
| 3349 | + "headers": {}, |
| 3350 | + "timeout_seconds": 30, |
| 3351 | + "verify_ssl": True, |
| 3352 | + } |
| 3353 | + |
| 3354 | + def _fake_client_session(self): |
| 3355 | + inst = MagicMock() |
| 3356 | + inst.initialize = AsyncMock() |
| 3357 | + inst.__aenter__ = AsyncMock(return_value=inst) |
| 3358 | + inst.__aexit__ = AsyncMock(return_value=None) |
| 3359 | + return inst |
| 3360 | + |
| 3361 | + @pytest.mark.asyncio |
| 3362 | + async def test_streamable_success_sse_not_invoked(self): |
| 3363 | + manager = MCPSessionManager() |
| 3364 | + try: |
| 3365 | + fake = self._fake_client_session() |
| 3366 | + stream_cm = MagicMock() |
| 3367 | + stream_cm.return_value.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock(), None)) |
| 3368 | + stream_cm.return_value.__aexit__ = AsyncMock(return_value=None) |
| 3369 | + with ( |
| 3370 | + patch("lfx.base.mcp.util.ClientSession", return_value=fake), |
| 3371 | + patch("mcp.client.streamable_http.streamablehttp_client", stream_cm), |
| 3372 | + patch("mcp.client.sse.sse_client") as sse_cm, |
| 3373 | + ): |
| 3374 | + session, task, transport, sse_lock = await manager._create_streamable_http_session( |
| 3375 | + "test_sess", self._connection_params(), None |
| 3376 | + ) |
| 3377 | + assert transport == "streamable_http" |
| 3378 | + assert sse_lock is False |
| 3379 | + assert session is fake |
| 3380 | + sse_cm.assert_not_called() |
| 3381 | + task.cancel() |
| 3382 | + with suppress(asyncio.CancelledError): |
| 3383 | + await task |
| 3384 | + finally: |
| 3385 | + await manager.cleanup_all() |
| 3386 | + |
| 3387 | + @pytest.mark.asyncio |
| 3388 | + async def test_transient_streamable_failure_no_sse_fallback(self): |
| 3389 | + manager = MCPSessionManager() |
| 3390 | + try: |
| 3391 | + fake = self._fake_client_session() |
| 3392 | + stream_cm = MagicMock() |
| 3393 | + stream_cm.return_value.__aenter__ = AsyncMock(side_effect=ConnectionError("connection refused")) |
| 3394 | + stream_cm.return_value.__aexit__ = AsyncMock(return_value=None) |
| 3395 | + with ( |
| 3396 | + patch("lfx.base.mcp.util.ClientSession", return_value=fake), |
| 3397 | + patch("mcp.client.streamable_http.streamablehttp_client", stream_cm), |
| 3398 | + patch("mcp.client.sse.sse_client") as sse_cm, |
| 3399 | + ): |
| 3400 | + with pytest.raises(ConnectionError, match="connection refused"): |
| 3401 | + await manager._create_streamable_http_session("test_sess", self._connection_params(), None) |
| 3402 | + sse_cm.assert_not_called() |
| 3403 | + finally: |
| 3404 | + await manager.cleanup_all() |
| 3405 | + |
| 3406 | + @pytest.mark.asyncio |
| 3407 | + async def test_streamable_404_triggers_sse(self): |
| 3408 | + manager = MCPSessionManager() |
| 3409 | + try: |
| 3410 | + req = httpx.Request("GET", "http://test-mcp.example/mcp") |
| 3411 | + resp = httpx.Response(404, request=req) |
| 3412 | + err404 = httpx.HTTPStatusError("not found", request=req, response=resp) |
| 3413 | + fake_stream = self._fake_client_session() |
| 3414 | + fake_sse = self._fake_client_session() |
| 3415 | + sessions = iter([fake_stream, fake_sse]) |
| 3416 | + |
| 3417 | + def client_session_factory(_r, _w, *_args): |
| 3418 | + return next(sessions) |
| 3419 | + |
| 3420 | + stream_cm = MagicMock() |
| 3421 | + stream_cm.return_value.__aenter__ = AsyncMock(side_effect=err404) |
| 3422 | + stream_cm.return_value.__aexit__ = AsyncMock(return_value=None) |
| 3423 | + sse_cm = MagicMock() |
| 3424 | + sse_cm.return_value.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock())) |
| 3425 | + sse_cm.return_value.__aexit__ = AsyncMock(return_value=None) |
| 3426 | + with ( |
| 3427 | + patch("lfx.base.mcp.util.ClientSession", side_effect=client_session_factory), |
| 3428 | + patch("mcp.client.streamable_http.streamablehttp_client", stream_cm), |
| 3429 | + patch("mcp.client.sse.sse_client", sse_cm), |
| 3430 | + ): |
| 3431 | + _session, task, transport, sse_lock = await manager._create_streamable_http_session( |
| 3432 | + "test_sess", self._connection_params(), None |
| 3433 | + ) |
| 3434 | + assert transport == "sse" |
| 3435 | + assert sse_lock is True |
| 3436 | + sse_cm.assert_called_once() |
| 3437 | + task.cancel() |
| 3438 | + with suppress(asyncio.CancelledError): |
| 3439 | + await task |
| 3440 | + finally: |
| 3441 | + await manager.cleanup_all() |
| 3442 | + |
| 3443 | + @pytest.mark.asyncio |
| 3444 | + async def test_validate_connectivity_mcp_session_terminated_returns_false(self): |
| 3445 | + manager = MCPSessionManager() |
| 3446 | + try: |
| 3447 | + mock_session = AsyncMock() |
| 3448 | + mock_session.list_tools = AsyncMock(side_effect=RuntimeError("Session terminated")) |
| 3449 | + assert await manager._validate_session_connectivity(mock_session) is False |
| 3450 | + finally: |
| 3451 | + await manager.cleanup_all() |
| 3452 | + |
| 3453 | + def test_classify_transient_includes_connection_and_taskgroup_hints(self): |
| 3454 | + assert _is_transient_streamable_http_error(ConnectionError("x")) is True |
| 3455 | + assert _is_transient_streamable_http_error(RuntimeError("unhandled errors in a TaskGroup")) is True |
| 3456 | + req = httpx.Request("GET", "http://x") |
| 3457 | + resp_404 = httpx.Response(404, request=req) |
| 3458 | + assert _is_transient_streamable_http_error(httpx.HTTPStatusError("x", request=req, response=resp_404)) is False |
| 3459 | + |
| 3460 | + def test_sse_fallback_after_404(self): |
| 3461 | + req = httpx.Request("GET", "http://x") |
| 3462 | + resp_404 = httpx.Response(404, request=req) |
| 3463 | + e = httpx.HTTPStatusError("x", request=req, response=resp_404) |
| 3464 | + assert _should_attempt_sse_after_streamable_failure(e) is True |
| 3465 | + assert _should_attempt_sse_after_streamable_failure(ConnectionError("x")) is False |
0 commit comments