diff --git a/src/frontend/tests/extended/features/mcp-server.spec.ts b/src/frontend/tests/extended/features/mcp-server.spec.ts
index 3e8f9bdd62..55f2cb445a 100644
--- a/src/frontend/tests/extended/features/mcp-server.spec.ts
+++ b/src/frontend/tests/extended/features/mcp-server.spec.ts
@@ -159,7 +159,7 @@ test(
timeout: 3000,
});
- await expect(page.getByTestId("sse-tab")).toBeDisabled({
+ await expect(page.getByTestId("http-tab")).toBeDisabled({
timeout: 3000,
});
@@ -242,7 +242,7 @@ test(
const sidebarButton = page.getByTestId("sidebar-add-mcp-server-button");
const fallbackButton = page.getByTestId("add-mcp-server-button-sidebar");
- if (await sidebarButton.isVisible({ timeout: 3000 }).catch(() => false)) {
+ if (await sidebarButton.isVisible({ timeout: 5000 }).catch(() => false)) {
await sidebarButton.click();
} else {
await fallbackButton.click();
@@ -267,6 +267,8 @@ test(
await page.getByTestId("add-mcp-server-button").click();
+ await page.waitForTimeout(1000);
+
await page.getByTestId(`add-component-button-${testName}`).click();
await expect(page.getByTestId("dropdown_str_tool")).toBeVisible({
@@ -521,7 +523,7 @@ test(
);
test(
- "SSE MCP server fields should persist after saving and editing",
+ "HTTP/SSE MCP server fields should persist after saving and editing",
{ tag: ["@release", "@workspace", "@components"] },
async ({ page }) => {
await awaitBootstrapTest(page);
@@ -560,16 +562,16 @@ test(
timeout: 30000,
});
- // Go to SSE tab and fill all fields
- await page.getByTestId("sse-tab").click();
- await page.waitForSelector('[data-testid="sse-name-input"]', {
+ // Go to HTTP tab and fill all fields
+ await page.getByTestId("http-tab").click();
+ await page.waitForSelector('[data-testid="http-name-input"]', {
state: "visible",
timeout: 30000,
});
// Test data with random suffix
const randomSuffix = Math.floor(Math.random() * 90000) + 10000; // 5-digit random number
- const testName = `test_sse_server_${randomSuffix}`;
+ const testName = `test_http_server_${randomSuffix}`;
const testUrl = "https://api.example.com/mcp";
const testHeaderKey1 = "Authorization";
const testHeaderValue1 = "Bearer token123";
@@ -581,26 +583,26 @@ test(
const testEnvValue2 = "3";
// Fill basic fields
- await page.getByTestId("sse-name-input").fill(testName);
- await page.getByTestId("sse-url-input").fill(testUrl);
+ await page.getByTestId("http-name-input").fill(testName);
+ await page.getByTestId("http-url-input").fill(testUrl);
// Add first header
- await page.getByTestId("sse-headers-key-0").fill(testHeaderKey1);
- await page.getByTestId("sse-headers-value-0").fill(testHeaderValue1);
+ await page.getByTestId("http-headers-key-0").fill(testHeaderKey1);
+ await page.getByTestId("http-headers-value-0").fill(testHeaderValue1);
// Add second header
- await page.getByTestId("sse-headers-plus-btn-0").click();
- await page.getByTestId("sse-headers-key-1").fill(testHeaderKey2);
- await page.getByTestId("sse-headers-value-1").fill(testHeaderValue2);
+ await page.getByTestId("http-headers-plus-btn-0").click();
+ await page.getByTestId("http-headers-key-1").fill(testHeaderKey2);
+ await page.getByTestId("http-headers-value-1").fill(testHeaderValue2);
// Add first environment variable
- await page.getByTestId("sse-env-key-0").fill(testEnvKey1);
- await page.getByTestId("sse-env-value-0").fill(testEnvValue1);
+ await page.getByTestId("http-env-key-0").fill(testEnvKey1);
+ await page.getByTestId("http-env-value-0").fill(testEnvValue1);
// Add second environment variable
- await page.getByTestId("sse-env-plus-btn-0").click();
- await page.getByTestId("sse-env-key-1").fill(testEnvKey2);
- await page.getByTestId("sse-env-value-1").fill(testEnvValue2);
+ await page.getByTestId("http-env-plus-btn-0").click();
+ await page.getByTestId("http-env-key-1").fill(testEnvKey2);
+ await page.getByTestId("http-env-value-1").fill(testEnvValue2);
// Save the server
await page.getByTestId("add-mcp-server-button").click();
@@ -641,32 +643,32 @@ test(
});
// Verify all fields persisted correctly
- expect(await page.getByTestId("sse-name-input").inputValue()).toBe(
+ expect(await page.getByTestId("http-name-input").inputValue()).toBe(
testName,
);
- expect(await page.getByTestId("sse-url-input").inputValue()).toBe(testUrl);
- expect(await page.getByTestId("sse-headers-key-0").inputValue()).toBe(
+ expect(await page.getByTestId("http-url-input").inputValue()).toBe(testUrl);
+ expect(await page.getByTestId("http-headers-key-0").inputValue()).toBe(
testHeaderKey1,
);
- expect(await page.getByTestId("sse-headers-value-0").inputValue()).toBe(
+ expect(await page.getByTestId("http-headers-value-0").inputValue()).toBe(
testHeaderValue1,
);
- expect(await page.getByTestId("sse-headers-key-1").inputValue()).toBe(
+ expect(await page.getByTestId("http-headers-key-1").inputValue()).toBe(
testHeaderKey2,
);
- expect(await page.getByTestId("sse-headers-value-1").inputValue()).toBe(
+ expect(await page.getByTestId("http-headers-value-1").inputValue()).toBe(
testHeaderValue2,
);
- expect(await page.getByTestId("sse-env-key-0").inputValue()).toBe(
+ expect(await page.getByTestId("http-env-key-0").inputValue()).toBe(
testEnvKey1,
);
- expect(await page.getByTestId("sse-env-value-0").inputValue()).toBe(
+ expect(await page.getByTestId("http-env-value-0").inputValue()).toBe(
testEnvValue1,
);
- expect(await page.getByTestId("sse-env-key-1").inputValue()).toBe(
+ expect(await page.getByTestId("http-env-key-1").inputValue()).toBe(
testEnvKey2,
);
- expect(await page.getByTestId("sse-env-value-1").inputValue()).toBe(
+ expect(await page.getByTestId("http-env-value-1").inputValue()).toBe(
testEnvValue2,
);
@@ -838,7 +840,7 @@ test(
timeout: 3000,
});
- await expect(page.getByTestId("sse-tab")).toBeDisabled({
+ await expect(page.getByTestId("http-tab")).toBeDisabled({
timeout: 3000,
});
@@ -982,3 +984,214 @@ test(
expect(fetchOptionCount2).toBeGreaterThan(0);
},
);
+
+test(
+ "Streamable HTTP MCP server with server-everything should load tools correctly",
+ { tag: ["@release", "@workspace", "@components"] },
+ async ({ page }) => {
+ // Start the MCP server with proper health checking
+ const server = "https://mcp.deepwiki.com/mcp";
+ await awaitBootstrapTest(page);
+
+ await page.waitForSelector('[data-testid="blank-flow"]', {
+ timeout: 30000,
+ });
+ await page.getByTestId("blank-flow").click();
+ await page.getByTestId("sidebar-search-input").click();
+ await page.getByTestId("sidebar-search-input").fill("mcp tools");
+
+ await page.waitForSelector('[data-testid="agentsMCP Tools"]', {
+ timeout: 30000,
+ });
+
+ await page
+ .getByTestId("agentsMCP Tools")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 100, y: 100 },
+ });
+
+ await adjustScreenView(page, { numberOfZoomOut: 3 });
+
+ try {
+ await page.getByText("Add MCP Server", { exact: true }).click({
+ timeout: 5000,
+ });
+ } catch (_error) {
+ await page.getByTestId("mcp-server-dropdown").click({ timeout: 3000 });
+ await page.getByText("Add MCP Server", { exact: true }).click({
+ timeout: 5000,
+ });
+ }
+
+ await page.waitForSelector('[data-testid="add-mcp-server-button"]', {
+ state: "visible",
+ timeout: 30000,
+ });
+
+ // Switch to HTTP tab for Streamable HTTP
+ await page.getByTestId("http-tab").click();
+
+ await page.waitForSelector('[data-testid="http-name-input"]', {
+ state: "visible",
+ timeout: 30000,
+ });
+
+ const randomSuffix = Math.floor(Math.random() * 90000) + 10000;
+ const testName = `test_streamable_http_${randomSuffix}`;
+
+ // Fill in the server details
+ await page.getByTestId("http-name-input").fill(testName);
+
+ // Use the HTTP endpoint URL
+ await page.getByTestId("http-url-input").fill(server);
+
+ await page.getByTestId("add-mcp-server-button").click();
+
+ // Wait for tools to load with proper timeout
+ await page.waitForSelector(
+ '[data-testid="dropdown_str_tool"]:not([disabled])',
+ {
+ timeout: 10000,
+ state: "visible",
+ },
+ );
+
+ await page.getByTestId("dropdown_str_tool").click();
+
+ // Check for tools from server
+ const toolOptions = page.locator('[data-testid*="-option"]');
+ const toolCount = await toolOptions.count();
+
+ // server-everything should have multiple tools (at least 5+)
+ expect(toolCount).toBeGreaterThan(5);
+
+ // Verify specific tools exist from server-everything
+ const readWikiStructureOption = page.getByTestId(
+ "read_wiki_structure-0-option",
+ );
+ expect(await readWikiStructureOption.count()).toBeGreaterThan(0);
+
+ // Select the option to verify it loads properly
+ await readWikiStructureOption.last().click();
+
+ // Wait for the tool input field to appear
+ await page.waitForSelector(
+ '[data-testid="popover-anchor-input-repoName"]',
+ {
+ state: "visible",
+ timeout: 10000,
+ },
+ );
+
+ // Verify the input field is present
+ await expect(
+ page.getByTestId("popover-anchor-input-repoName"),
+ ).toBeVisible();
+ },
+);
+
+test(
+ "SSE MCP server with deepwiki should load tools correctly",
+ { tag: ["@release", "@workspace", "@components"] },
+ async ({ page }) => {
+ // Start the MCP server with proper health checking
+ const server = "https://mcp.deepwiki.com/sse";
+
+ await awaitBootstrapTest(page);
+
+ await page.waitForSelector('[data-testid="blank-flow"]', {
+ timeout: 30000,
+ });
+ await page.getByTestId("blank-flow").click();
+ await page.getByTestId("sidebar-search-input").click();
+ await page.getByTestId("sidebar-search-input").fill("mcp tools");
+
+ await page.waitForSelector('[data-testid="agentsMCP Tools"]', {
+ timeout: 30000,
+ });
+
+ await page
+ .getByTestId("agentsMCP Tools")
+ .dragTo(page.locator('//*[@id="react-flow-id"]'), {
+ targetPosition: { x: 100, y: 100 },
+ });
+
+ await adjustScreenView(page, { numberOfZoomOut: 3 });
+
+ try {
+ await page.getByText("Add MCP Server", { exact: true }).click({
+ timeout: 5000,
+ });
+ } catch (_error) {
+ await page.getByTestId("mcp-server-dropdown").click({ timeout: 3000 });
+ await page.getByText("Add MCP Server", { exact: true }).click({
+ timeout: 5000,
+ });
+ }
+
+ await page.waitForSelector('[data-testid="add-mcp-server-button"]', {
+ state: "visible",
+ timeout: 30000,
+ });
+
+ // Switch to HTTP tab for SSE
+ await page.getByTestId("http-tab").click();
+
+ await page.waitForSelector('[data-testid="http-name-input"]', {
+ state: "visible",
+ timeout: 30000,
+ });
+
+ const randomSuffix = Math.floor(Math.random() * 90000) + 10000;
+ const testName = `test_sse_${randomSuffix}`;
+
+ // Fill in the server details
+ await page.getByTestId("http-name-input").fill(testName);
+
+ // Use the HTTP endpoint URL
+ await page.getByTestId("http-url-input").fill(server);
+
+ await page.getByTestId("add-mcp-server-button").click();
+
+ // Wait for tools to load with proper timeout
+ await page.waitForSelector(
+ '[data-testid="dropdown_str_tool"]:not([disabled])',
+ {
+ timeout: 10000,
+ state: "visible",
+ },
+ );
+
+ await page.getByTestId("dropdown_str_tool").click();
+
+ // Check for tools from wiki
+ const toolOptions = page.locator('[data-testid*="-option"]');
+ const toolCount = await toolOptions.count();
+
+ // server-everything should have multiple tools (at least 5+)
+ expect(toolCount).toBeGreaterThan(5);
+
+ // Verify specific tools exist from server-everything
+ const readWikiStructureOption = page.getByTestId(
+ "read_wiki_structure-0-option",
+ );
+ expect(await readWikiStructureOption.count()).toBeGreaterThan(0);
+
+ // Select the readWikiStructure to verify it loads properly
+ await readWikiStructureOption.last().click();
+
+ // Wait for the tool input field to appear
+ await page.waitForSelector(
+ '[data-testid="popover-anchor-input-repoName"]',
+ {
+ state: "visible",
+ timeout: 10000,
+ },
+ );
+
+ // Verify the input field is present
+ await expect(
+ page.getByTestId("popover-anchor-input-repoName"),
+ ).toBeVisible();
+ },
+);
diff --git a/src/lfx/src/lfx/base/mcp/util.py b/src/lfx/src/lfx/base/mcp/util.py
index 6adad58714..55e87433b4 100644
--- a/src/lfx/src/lfx/base/mcp/util.py
+++ b/src/lfx/src/lfx/base/mcp/util.py
@@ -28,8 +28,12 @@ HTTP_ERROR_STATUS_CODE = httpx_codes.BAD_REQUEST # HTTP status code for client
# HTTP status codes used in validation
HTTP_NOT_FOUND = 404
+HTTP_METHOD_NOT_ALLOWED = 405
+HTTP_NOT_ACCEPTABLE = 406
HTTP_BAD_REQUEST = 400
HTTP_INTERNAL_SERVER_ERROR = 500
+HTTP_UNAUTHORIZED = 401
+HTTP_FORBIDDEN = 403
# MCP Session Manager constants
settings = get_settings_service().settings
@@ -378,8 +382,8 @@ def _validate_node_installation(command: str) -> str:
async def _validate_connection_params(mode: str, command: str | None = None, url: str | None = None) -> None:
"""Validate connection parameters based on mode."""
- if mode not in ["Stdio", "SSE"]:
- msg = f"Invalid mode: {mode}. Must be either 'Stdio' or 'SSE'"
+ if mode not in ["Stdio", "Streamable_HTTP", "SSE"]:
+ msg = f"Invalid mode: {mode}. Must be either 'Stdio', 'Streamable_HTTP', or 'SSE'"
raise ValueError(msg)
if mode == "Stdio" and not command:
@@ -387,8 +391,8 @@ async def _validate_connection_params(mode: str, command: str | None = None, url
raise ValueError(msg)
if mode == "Stdio" and command:
_validate_node_installation(command)
- if mode == "SSE" and not url:
- msg = "URL is required for SSE mode"
+ if mode in ["Streamable_HTTP", "SSE"] and not url:
+ msg = f"URL is required for {mode} mode"
raise ValueError(msg)
@@ -400,6 +404,7 @@ class MCPSessionManager:
2. Maximum session limits per server to prevent resource exhaustion
3. Idle timeout for automatic session cleanup
4. Periodic cleanup of stale sessions
+ 5. Transport preference caching to avoid retrying failed transports
"""
def __init__(self):
@@ -410,6 +415,9 @@ class MCPSessionManager:
self._context_to_session: dict[str, tuple[str, str]] = {}
# Reference count for each active (server_key, session_id)
self._session_refcount: dict[tuple[str, str], int] = {}
+ # Cache which transport works for each server to avoid retrying failed transports
+ # server_key -> "streamable_http" | "sse"
+ self._transport_preference: dict[str, str] = {}
self._cleanup_task = None
self._start_cleanup_task()
@@ -467,15 +475,16 @@ class MCPSessionManager:
env_str = str(sorted((connection_params.env or {}).items()))
key_input = f"{command_str}|{env_str}"
return f"stdio_{hash(key_input)}"
- elif transport_type == "sse" and (isinstance(connection_params, dict) and "url" in connection_params):
+ elif transport_type == "streamable_http" and (
+ isinstance(connection_params, dict) and "url" in connection_params
+ ):
# Include URL and headers for uniqueness
url = connection_params["url"]
headers = str(sorted((connection_params.get("headers", {})).items()))
key_input = f"{url}|{headers}"
- return f"sse_{hash(key_input)}"
+ return f"streamable_http_{hash(key_input)}"
# Fallback to a generic key
- # TODO: add option for streamable HTTP in future.
return f"{transport_type}_{hash(str(connection_params))}"
async def _validate_session_connectivity(self, session) -> bool:
@@ -525,7 +534,7 @@ class MCPSessionManager:
"""Get or create a session with improved reuse strategy.
The key insight is that we should reuse sessions based on the server
- identity (command + args for stdio, URL for SSE) rather than the context_id.
+ identity (command + args for stdio, URL for Streamable HTTP) rather than the context_id.
This prevents creating a new subprocess for each unique context.
"""
server_key = self._get_server_key(connection_params, transport_type)
@@ -578,17 +587,24 @@ class MCPSessionManager:
if transport_type == "stdio":
session, task = await self._create_stdio_session(session_id, connection_params)
- elif transport_type == "sse":
- session, task = await self._create_sse_session(session_id, connection_params)
+ actual_transport = "stdio"
+ elif transport_type == "streamable_http":
+ # Pass the cached transport preference if available
+ preferred_transport = self._transport_preference.get(server_key)
+ session, task, actual_transport = await self._create_streamable_http_session(
+ session_id, connection_params, preferred_transport
+ )
+ # Cache the transport that worked for future connections
+ self._transport_preference[server_key] = actual_transport
else:
msg = f"Unknown transport type: {transport_type}"
raise ValueError(msg)
- # Store session info
+ # Store session info with the actual transport used
sessions[session_id] = {
"session": session,
"task": task,
- "type": transport_type,
+ "type": actual_transport,
"last_used": asyncio.get_event_loop().time(),
}
@@ -634,9 +650,9 @@ class MCPSessionManager:
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
- # Wait for session to be ready
+ # Wait for session to be ready (use longer timeout for remote connections)
try:
- session = await asyncio.wait_for(session_future, timeout=10.0)
+ session = await asyncio.wait_for(session_future, timeout=30.0)
except asyncio.TimeoutError as timeout_err:
# Clean up the failed task
if not task.done():
@@ -652,50 +668,136 @@ class MCPSessionManager:
return session, task
- async def _create_sse_session(self, session_id: str, connection_params):
- """Create a new SSE session as a background task to avoid context issues."""
+ async def _create_streamable_http_session(
+ self, session_id: str, connection_params, preferred_transport: str | None = None
+ ):
+ """Create a new Streamable HTTP session with SSE fallback as a background task to avoid context issues.
+
+ Args:
+ session_id: Unique identifier for this session
+ connection_params: Connection parameters including URL, headers, timeouts
+ preferred_transport: If set to "sse", skip Streamable HTTP and go directly to SSE
+
+ Returns:
+ tuple: (session, task, transport_used) where transport_used is "streamable_http" or "sse"
+ """
import asyncio
from mcp.client.sse import sse_client
+ from mcp.client.streamable_http import streamablehttp_client
# Create a future to get the session
session_future: asyncio.Future[ClientSession] = asyncio.Future()
+ # Track which transport succeeded
+ used_transport: list[str] = []
async def session_task():
"""Background task that keeps the session alive."""
- try:
- async with sse_client(
- connection_params["url"],
- connection_params["headers"],
- connection_params["timeout_seconds"],
- connection_params["sse_read_timeout_seconds"],
- ) as (read, write):
- session = ClientSession(read, write)
- async with session:
- await session.initialize()
- # Signal that session is ready
- session_future.set_result(session)
+ streamable_error = None
- # Keep the session alive until cancelled
- import anyio
+ # Skip Streamable HTTP if we know SSE works for this server
+ if preferred_transport != "sse":
+ # Try Streamable HTTP first with a quick timeout
+ try:
+ await logger.adebug(f"Attempting Streamable HTTP connection for session {session_id}")
+ # Use a shorter timeout for the initial connection attempt (2 seconds)
+ async with streamablehttp_client(
+ url=connection_params["url"],
+ headers=connection_params["headers"],
+ timeout=connection_params["timeout_seconds"],
+ ) as (read, write, _):
+ session = ClientSession(read, write)
+ async with session:
+ # Initialize with a timeout to fail fast
+ await asyncio.wait_for(session.initialize(), timeout=2.0)
+ used_transport.append("streamable_http")
+ await logger.ainfo(f"Session {session_id} connected via Streamable HTTP")
+ # Signal that session is ready
+ session_future.set_result(session)
- event = anyio.Event()
- try:
- await event.wait()
- except asyncio.CancelledError:
- await logger.ainfo(f"Session {session_id} is shutting down")
- except Exception as e: # noqa: BLE001
- if not session_future.done():
- session_future.set_exception(e)
+ # Keep the session alive until cancelled
+ import anyio
+
+ event = anyio.Event()
+ try:
+ await event.wait()
+ except asyncio.CancelledError:
+ await logger.ainfo(f"Session {session_id} (Streamable HTTP) is shutting down")
+ except (asyncio.TimeoutError, Exception) as e: # noqa: BLE001
+ # If Streamable HTTP fails or times out, try SSE as fallback immediately
+ streamable_error = e
+ error_type = "timed out" if isinstance(e, asyncio.TimeoutError) else "failed"
+ await logger.awarning(
+ f"Streamable HTTP {error_type} for session {session_id}: {e}. Falling back to SSE..."
+ )
+ else:
+ await logger.adebug(f"Skipping Streamable HTTP for session {session_id}, using cached SSE preference")
+
+ # Try SSE if Streamable HTTP failed or if SSE is preferred
+ if streamable_error is not None or preferred_transport == "sse":
+ try:
+ await logger.adebug(f"Attempting SSE connection for session {session_id}")
+ # Extract SSE read timeout from connection params, default to 30s if not present
+ sse_read_timeout = connection_params.get("sse_read_timeout_seconds", 30)
+
+ async with sse_client(
+ connection_params["url"],
+ connection_params["headers"],
+ connection_params["timeout_seconds"],
+ sse_read_timeout,
+ ) as (read, write):
+ session = ClientSession(read, write)
+ async with session:
+ await session.initialize()
+ used_transport.append("sse")
+ fallback_msg = " (fallback)" if streamable_error else " (preferred)"
+ await logger.ainfo(f"Session {session_id} connected via SSE{fallback_msg}")
+ # Signal that session is ready
+ if not session_future.done():
+ session_future.set_result(session)
+
+ # Keep the session alive until cancelled
+ import anyio
+
+ event = anyio.Event()
+ try:
+ await event.wait()
+ except asyncio.CancelledError:
+ await logger.ainfo(f"Session {session_id} (SSE) is shutting down")
+ except Exception as sse_error: # noqa: BLE001
+ # Both transports failed (or just SSE if it was preferred)
+ if streamable_error:
+ await logger.aerror(
+ f"Both Streamable HTTP and SSE failed for session {session_id}. "
+ f"Streamable HTTP error: {streamable_error}. SSE error: {sse_error}"
+ )
+ if not session_future.done():
+ session_future.set_exception(
+ ValueError(
+ f"Failed to connect via Streamable HTTP ({streamable_error}) or SSE ({sse_error})"
+ )
+ )
+ else:
+ await logger.aerror(f"SSE connection failed for session {session_id}: {sse_error}")
+ if not session_future.done():
+ session_future.set_exception(ValueError(f"Failed to connect via SSE: {sse_error}"))
# Start the background task
task = asyncio.create_task(session_task())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
- # Wait for session to be ready
+ # Wait for session to be ready (use longer timeout for remote connections)
try:
- session = await asyncio.wait_for(session_future, timeout=10.0)
+ session = await asyncio.wait_for(session_future, timeout=30.0)
+ # Log which transport was used
+ if used_transport:
+ transport_used = used_transport[0]
+ await logger.ainfo(f"Session {session_id} successfully established using {transport_used}")
+ return session, task, transport_used
+ # This shouldn't happen, but handle it just in case
+ msg = f"Session {session_id} established but transport not recorded"
+ raise ValueError(msg)
except asyncio.TimeoutError as timeout_err:
# Clean up the failed task
if not task.done():
@@ -705,12 +807,10 @@ class MCPSessionManager:
with contextlib.suppress(asyncio.CancelledError):
await task
self._background_tasks.discard(task)
- msg = f"Timeout waiting for SSE session {session_id} to initialize"
+ msg = f"Timeout waiting for Streamable HTTP/SSE session {session_id} to initialize"
await logger.aerror(msg)
raise ValueError(msg) from timeout_err
- return session, task
-
async def _cleanup_session_by_id(self, server_key: str, session_id: str):
"""Clean up a specific session by server key and session ID."""
if server_key not in self.sessions_by_server:
@@ -1056,7 +1156,7 @@ class MCPStdioClient:
await self.disconnect()
-class MCPSseClient:
+class MCPStreamableHttpClient:
def __init__(self, component_cache=None):
self.session: ClientSession | None = None
self._connection_params = None
@@ -1080,67 +1180,15 @@ class MCPSseClient:
self._component_cache.set("mcp_session_manager", session_manager)
return session_manager
- async def validate_url(self, url: str | None, headers: dict[str, str] | None = None) -> tuple[bool, str]:
- """Validate the SSE URL before attempting connection."""
+ async def validate_url(self, url: str | None) -> tuple[bool, str]:
+ """Validate the Streamable HTTP URL before attempting connection."""
try:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return False, "Invalid URL format. Must include scheme (http/https) and host."
-
- async with httpx.AsyncClient() as client:
- try:
- # For SSE endpoints, try a GET request with short timeout
- # Many SSE servers don't support HEAD requests and return 404
- response = await client.get(
- url, timeout=2.0, headers={"Accept": "text/event-stream", **(headers or {})}
- )
-
- # For SSE, we expect the server to either:
- # 1. Start streaming (200)
- # 2. Return 404 if HEAD/GET without proper SSE handshake is not supported
- # 3. Return other status codes that we should handle gracefully
-
- # Don't fail on 404 since many SSE endpoints return this for non-SSE requests
- if response.status_code == HTTP_NOT_FOUND:
- # This is likely an SSE endpoint that doesn't support regular GET
- # Let the actual SSE connection attempt handle this
- return True, ""
-
- # Fail on client errors except 404, but allow server errors and redirects
- if (
- HTTP_BAD_REQUEST <= response.status_code < HTTP_INTERNAL_SERVER_ERROR
- and response.status_code != HTTP_NOT_FOUND
- ):
- return False, f"Server returned client error status: {response.status_code}"
-
- except httpx.TimeoutException:
- # Timeout on a short request might indicate the server is trying to stream
- # This is actually expected behavior for SSE endpoints
- return True, ""
- except httpx.NetworkError:
- return False, "Network error. Could not reach the server."
- else:
- return True, ""
-
- except (httpx.HTTPError, ValueError, OSError) as e:
+ except (ValueError, OSError) as e:
return False, f"URL validation error: {e!s}"
-
- async def pre_check_redirect(self, url: str | None, headers: dict[str, str] | None = None) -> str | None:
- """Check for redirects and return the final URL."""
- if url is None:
- return url
- try:
- async with httpx.AsyncClient(follow_redirects=False) as client:
- # Use GET with SSE headers instead of HEAD since many SSE servers don't support HEAD
- response = await client.get(
- url, timeout=2.0, headers={"Accept": "text/event-stream", **(headers or {})}
- )
- if response.status_code == httpx.codes.TEMPORARY_REDIRECT:
- return response.headers.get("Location", url)
- # Don't treat 404 as an error here - let the main connection handle it
- except (httpx.RequestError, httpx.HTTPError) as e:
- await logger.awarning(f"Error checking redirects: {e}")
- return url
+ return True, ""
async def _connect_to_server(
self,
@@ -1149,27 +1197,31 @@ class MCPSseClient:
timeout_seconds: int = 30,
sse_read_timeout_seconds: int = 30,
) -> list[StructuredTool]:
- """Connect to MCP server using SSE transport (SDK style)."""
+ """Connect to MCP server using Streamable HTTP transport with SSE fallback (SDK style)."""
# Validate and sanitize headers early
validated_headers = _process_headers(headers)
if url is None:
- msg = "URL is required for SSE mode"
- raise ValueError(msg)
- is_valid, error_msg = await self.validate_url(url, validated_headers)
- if not is_valid:
- msg = f"Invalid SSE URL ({url}): {error_msg}"
+ msg = "URL is required for StreamableHTTP or SSE mode"
raise ValueError(msg)
- url = await self.pre_check_redirect(url, validated_headers)
-
- # Store connection parameters for later use in run_tool
- self._connection_params = {
- "url": url,
- "headers": validated_headers,
- "timeout_seconds": timeout_seconds,
- "sse_read_timeout_seconds": sse_read_timeout_seconds,
- }
+ # Only validate URL if we don't have a cached session
+ # This avoids expensive HTTP validation calls when reusing sessions
+ if not self._connected or not self._connection_params:
+ is_valid, error_msg = await self.validate_url(url)
+ if not is_valid:
+ msg = f"Invalid Streamable HTTP or SSE URL ({url}): {error_msg}"
+ raise ValueError(msg)
+ # Store connection parameters for later use in run_tool
+ # Include SSE read timeout for fallback
+ self._connection_params = {
+ "url": url,
+ "headers": validated_headers,
+ "timeout_seconds": timeout_seconds,
+ "sse_read_timeout_seconds": sse_read_timeout_seconds,
+ }
+ elif headers:
+ self._connection_params["headers"] = validated_headers
# If no session context is set, create a default one
if not self._session_context:
@@ -1177,18 +1229,21 @@ class MCPSseClient:
import uuid
param_hash = uuid.uuid4().hex[:8]
- self._session_context = f"default_sse_{param_hash}"
+ self._session_context = f"default_http_{param_hash}"
- # Get or create a persistent session
+ # Get or create a persistent session (will try Streamable HTTP, then SSE fallback)
session = await self._get_or_create_session()
response = await session.list_tools()
self._connected = True
return response.tools
- async def connect_to_server(self, url: str, headers: dict[str, str] | None = None) -> list[StructuredTool]:
- """Connect to MCP server using SSE transport (SDK style)."""
+ async def connect_to_server(
+ self, url: str, headers: dict[str, str] | None = None, sse_read_timeout_seconds: int = 30
+ ) -> list[StructuredTool]:
+ """Connect to MCP server using Streamable HTTP with SSE fallback transport (SDK style)."""
return await asyncio.wait_for(
- self._connect_to_server(url, headers), timeout=get_settings_service().settings.mcp_server_timeout
+ self._connect_to_server(url, headers, sse_read_timeout_seconds=sse_read_timeout_seconds),
+ timeout=get_settings_service().settings.mcp_server_timeout,
)
def set_session_context(self, context_id: str):
@@ -1204,12 +1259,14 @@ class MCPSseClient:
# Use cached session manager to get/create persistent session
session_manager = self._get_session_manager()
# Cache session so we can access server-assigned session_id later for DELETE
- self.session = await session_manager.get_session(self._session_context, self._connection_params, "sse")
+ self.session = await session_manager.get_session(
+ self._session_context, self._connection_params, "streamable_http"
+ )
return self.session
async def _terminate_remote_session(self) -> None:
"""Attempt to explicitly terminate the remote MCP session via HTTP DELETE (best-effort)."""
- # Only relevant for SSE transport
+ # Only relevant for Streamable HTTP or SSE transport
if not self._connection_params or "url" not in self._connection_params:
return
@@ -1255,7 +1312,7 @@ class MCPSseClient:
import uuid
param_hash = uuid.uuid4().hex[:8]
- self._session_context = f"default_sse_{param_hash}"
+ self._session_context = f"default_http_{param_hash}"
max_retries = 2
last_error_type = None
@@ -1326,7 +1383,7 @@ class MCPSseClient:
await logger.aerror(msg)
# Clean up failed session from cache
if self._session_context and self._component_cache:
- cache_key = f"mcp_session_sse_{self._session_context}"
+ cache_key = f"mcp_session_http_{self._session_context}"
self._component_cache.delete(cache_key)
self._connected = False
raise ValueError(msg) from e
@@ -1364,11 +1421,17 @@ class MCPSseClient:
await self.disconnect()
+# Backward compatibility: MCPSseClient is now an alias for MCPStreamableHttpClient
+# The new client supports both Streamable HTTP and SSE with automatic fallback
+MCPSseClient = MCPStreamableHttpClient
+
+
async def update_tools(
server_name: str,
server_config: dict,
mcp_stdio_client: MCPStdioClient | None = None,
- mcp_sse_client: MCPSseClient | None = None,
+ mcp_streamable_http_client: MCPStreamableHttpClient | None = None,
+ mcp_sse_client: MCPStreamableHttpClient | None = None, # Backward compatibility
) -> tuple[str, list[StructuredTool], dict[str, StructuredTool]]:
"""Fetch server config and update available tools."""
if server_config is None:
@@ -1377,11 +1440,17 @@ async def update_tools(
return "", [], {}
if mcp_stdio_client is None:
mcp_stdio_client = MCPStdioClient()
- if mcp_sse_client is None:
- mcp_sse_client = MCPSseClient()
+
+ # Backward compatibility: accept mcp_sse_client parameter
+ if mcp_streamable_http_client is None:
+ mcp_streamable_http_client = mcp_sse_client if mcp_sse_client is not None else MCPStreamableHttpClient()
# Fetch server config from backend
- mode = "Stdio" if "command" in server_config else "SSE" if "url" in server_config else ""
+ # Determine mode from config, defaulting to Streamable_HTTP if URL present
+ mode = server_config.get("mode", "")
+ if not mode:
+ mode = "Stdio" if "command" in server_config else "Streamable_HTTP" if "url" in server_config else ""
+
command = server_config.get("command", "")
url = server_config.get("url", "")
tools = []
@@ -1394,7 +1463,7 @@ async def update_tools(
raise
# Determine connection type and parameters
- client: MCPStdioClient | MCPSseClient | None = None
+ client: MCPStdioClient | MCPStreamableHttpClient | None = None
if mode == "Stdio":
# Stdio connection
args = server_config.get("args", [])
@@ -1402,10 +1471,10 @@ async def update_tools(
full_command = " ".join([command, *args])
tools = await mcp_stdio_client.connect_to_server(full_command, env)
client = mcp_stdio_client
- elif mode == "SSE":
- # SSE connection
- tools = await mcp_sse_client.connect_to_server(url, headers=headers)
- client = mcp_sse_client
+ elif mode in ["Streamable_HTTP", "SSE"]:
+ # Streamable HTTP connection with SSE fallback
+ tools = await mcp_streamable_http_client.connect_to_server(url, headers=headers)
+ client = mcp_streamable_http_client
else:
logger.error(f"Invalid MCP server mode for '{server_name}': {mode}")
return "", [], {}
diff --git a/src/lfx/src/lfx/components/agents/mcp_component.py b/src/lfx/src/lfx/components/agents/mcp_component.py
index 1b1192c7c1..3cc1c58ec5 100644
--- a/src/lfx/src/lfx/components/agents/mcp_component.py
+++ b/src/lfx/src/lfx/components/agents/mcp_component.py
@@ -7,7 +7,12 @@ from typing import Any
from langchain_core.tools import StructuredTool # noqa: TC002
from lfx.base.agents.utils import maybe_unflatten_dict, safe_cache_get, safe_cache_set
-from lfx.base.mcp.util import MCPSseClient, MCPStdioClient, create_input_schema_from_json_schema, update_tools
+from lfx.base.mcp.util import (
+ MCPStdioClient,
+ MCPStreamableHttpClient,
+ create_input_schema_from_json_schema,
+ update_tools,
+)
from lfx.custom.custom_component.component_with_cache import ComponentWithCache
from lfx.inputs.inputs import InputTypes # noqa: TC001
from lfx.io import BoolInput, DropdownInput, McpInput, MessageTextInput, Output
@@ -32,7 +37,9 @@ class MCPToolsComponent(ComponentWithCache):
# Initialize clients with access to the component cache
self.stdio_client: MCPStdioClient = MCPStdioClient(component_cache=self._shared_component_cache)
- self.sse_client: MCPSseClient = MCPSseClient(component_cache=self._shared_component_cache)
+ self.streamable_http_client: MCPStreamableHttpClient = MCPStreamableHttpClient(
+ component_cache=self._shared_component_cache
+ )
def _ensure_cache_structure(self):
"""Ensure the cache has the required structure."""
@@ -207,7 +214,7 @@ class MCPToolsComponent(ComponentWithCache):
server_name=server_name,
server_config=server_config,
mcp_stdio_client=self.stdio_client,
- mcp_sse_client=self.sse_client,
+ mcp_streamable_http_client=self.streamable_http_client,
)
self.tool_names = [tool.name for tool in tool_list if hasattr(tool, "name")]
@@ -496,7 +503,7 @@ class MCPToolsComponent(ComponentWithCache):
session_context = self._get_session_context()
if session_context:
self.stdio_client.set_session_context(session_context)
- self.sse_client.set_session_context(session_context)
+ self.streamable_http_client.set_session_context(session_context)
exec_tool = self._tool_cache[self.tool]
tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool]