Skip to content

Commit 5fd7cd1

Browse files
committed
Merge branch 'fix/issue-12529-propagate-api-key-to-nested-mcp' of github.com:octo-patch/langflow into fix/issue-12529-propagate-api-key-to-nested-mcp
2 parents 585c0fb + 0d5a98d commit 5fd7cd1

7 files changed

Lines changed: 1040 additions & 300 deletions

File tree

src/backend/base/langflow/initial_setup/starter_projects/Nvidia Remix.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

src/backend/tests/unit/base/mcp/test_mcp_util.py

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,24 @@
66
- Utility functions for name sanitization and schema conversion
77
"""
88

9+
import asyncio
910
import re
1011
import shutil
1112
import sys
13+
from contextlib import suppress
1214
from unittest.mock import AsyncMock, MagicMock, patch
1315

16+
import httpx
1417
import pytest
1518
from lfx.base.mcp import util
1619
from lfx.base.mcp.util import (
1720
MCPSessionManager,
1821
MCPSseClient,
1922
MCPStdioClient,
2023
MCPStreamableHttpClient,
24+
_is_transient_streamable_http_error,
2125
_process_headers,
26+
_should_attempt_sse_after_streamable_failure,
2227
update_tools,
2328
validate_headers,
2429
)
@@ -1981,16 +1986,17 @@ async def mock_get_session_side_effect():
19811986
patch.object(sse_client, "_get_or_create_session", side_effect=mock_get_session_side_effect),
19821987
patch.object(sse_client, "_get_session_manager") as mock_get_manager,
19831988
):
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")
19851992
mock_get_manager.return_value = mock_manager
19861993

19871994
result = await sse_client.run_tool("test_tool", {"param": "value"})
19881995

19891996
# Should have retried and succeeded on second attempt
19901997
assert call_count == 2
19911998
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")
19942000

19952001

19962002
class TestMCPStructuredTool:
@@ -3332,3 +3338,128 @@ async def on_tool_end(self, output, *, run_id, parent_run_id=None, **kwargs):
33323338
assert handler.outputs[0].name == "get_image"
33333339
assert handler.outputs[0].artifact is raw
33343340
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

Comments
 (0)