fix: add error handling and message on mcp servers, fixed tool mode on mcp (#8717)

* Added mcp error handling

* Added error param to servers

* Added error display on mcp servers page

* Added error display on mcp component

* Added error handling for other types of errors

* uv lock update and add error handling

* update mcp version

* fixed tool mode not working on mcp component

* update mcp to 1.9.4

* Removed server parameters custom commands on connection to stdio

* removed unused import

* disable mcp notice

* Removed drop state when home type is mcp

* Added loading before showing tools

* Updated mcp to 1.9.4

* Decreased mcp timeout

* Implemented error surfacing with exec command

* removed non default keys either way when mcp_server is used

* update to session handling

* [autofix.ci] apply automated fixes

* updated it to check if it is the same server to not clear on startup

* update to components

* Update mcp_component.py

* Update mcp_component.py

* Update mcp_component.py

---------

Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
Lucas Oliveira 2025-06-26 19:22:01 -03:00 committed by GitHub
commit f4d761e63d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 178 additions and 45 deletions

View file

@ -105,7 +105,7 @@ dependencies = [
"arize-phoenix-otel>=0.6.1",
"openinference-instrumentation-langchain>=0.1.29",
"crewai==0.102.0",
"mcp>=0.9.1",
"mcp>=1.9.4",
"uv>=0.5.7",
"scipy>=1.14.1",
"ag2>=0.1.0",

View file

@ -122,13 +122,52 @@ async def get_servers(
)
server_info["mode"] = mode.lower()
server_info["toolsCount"] = len(tool_list)
if len(tool_list) == 0:
server_info["error"] = "No tools found"
except ValueError as e:
# Configuration validation errors, invalid URLs, etc.
logger.error(f"Configuration error for server {server_name}: {e}")
server_info["error"] = f"Configuration error: {e}"
except (ConnectionError, TimeoutError) as e:
# Network connection and timeout issues
logger.error(f"Connection error for server {server_name}: {e}")
server_info["error"] = f"Connection failed: {e}"
except OSError as e:
# System-level errors (process execution, file access)
logger.error(f"System error for server {server_name}: {e}")
server_info["error"] = f"System error: {e}"
except (KeyError, TypeError) as e:
# Data parsing and access errors
logger.error(f"Data error for server {server_name}: {e}")
server_info["error"] = f"Configuration data error: {e}"
except (RuntimeError, ProcessLookupError, PermissionError) as e:
# Runtime and process-related errors
logger.error(f"Runtime error for server {server_name}: {e}")
server_info["error"] = f"Runtime error: {e}"
except Exception as e: # noqa: BLE001
logger.exception(f"Error checking server {server_name}: {e}")
# Generic catch-all for other exceptions (including ExceptionGroup)
if hasattr(e, "exceptions") and e.exceptions:
# Extract the first underlying exception for a more meaningful error message
underlying_error = e.exceptions[0]
logger.exception(f"Error checking server {server_name}: {underlying_error}")
server_info["error"] = f"Error loading server: {underlying_error}"
else:
logger.exception(f"Error checking server {server_name}: {e}")
server_info["error"] = f"Error loading server: {e}"
return server_info
async def check_server_with_timeout(server_name: str) -> dict:
try:
return await asyncio.wait_for(
check_server(server_name), timeout=get_settings_service().settings.mcp_server_timeout
)
except asyncio.TimeoutError:
logger.error(f"Timeout checking server {server_name}")
return {"name": server_name, "mode": None, "toolsCount": None, "error": "Server check timed out."}
# Run all server checks concurrently
tasks = [check_server(server) for server in server_list["mcpServers"]]
return await asyncio.gather(*tasks, return_exceptions=False)
tasks = [check_server_with_timeout(server) for server in server_list["mcpServers"]]
return await asyncio.gather(*tasks, return_exceptions=True)
@router.get("/servers/{server_name}")

View file

@ -16,6 +16,7 @@ from pydantic import BaseModel, Field, create_model
from sqlmodel import select
from langflow.services.database.models.flow.model import Flow
from langflow.services.deps import get_settings_service
HTTP_ERROR_STATUS_CODE = httpx_codes.BAD_REQUEST # HTTP status code for client errors
NULLABLE_TYPE_LENGTH = 2 # Number of types in a nullable union (the type itself + null)
@ -300,7 +301,7 @@ class MCPStdioClient:
self._connection_params = None
self._connected = False
async def connect_to_server(self, command_str: str, env: dict[str, str] | None = None) -> list[StructuredTool]:
async def _connect_to_server(self, command_str: str, env: dict[str, str] | None = None) -> list[StructuredTool]:
"""Connect to MCP server using stdio transport (SDK style)."""
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client
@ -320,7 +321,7 @@ class MCPStdioClient:
else:
server_params = StdioServerParameters(
command="bash",
args=["-c", f"{command_str} || echo 'Command failed with exit code $?' >&2"],
args=["-c", f"exec {command_str} || echo 'Command failed with exit code $?' >&2"],
env=env_data,
)
@ -339,6 +340,19 @@ class MCPStdioClient:
self._connected = False
return []
async def connect_to_server(self, command_str: str, env: dict[str, str] | None = None) -> list[StructuredTool]:
"""Connect to MCP server using stdio transport (SDK style)."""
try:
return await asyncio.wait_for(
self._connect_to_server(command_str, env), timeout=get_settings_service().settings.mcp_server_timeout
)
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
logger.error(f"Failed to connect to MCP stdio server: {e}")
self._connection_params = None
self._connected = False
msg = f"Failed to connect to MCP stdio server: {e}"
raise ValueError(msg) from e
async def disconnect(self):
"""Properly close the connection and clean up resources."""
self.session = None
@ -425,7 +439,7 @@ class MCPSseClient:
logger.warning(f"Error checking redirects: {e}")
return url
async def connect_to_server(
async def _connect_to_server(
self,
url: str | None,
headers: dict[str, str] | None = None,
@ -464,6 +478,19 @@ class MCPSseClient:
response = await session.list_tools()
self._connected = True
return response.tools
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
logger.error(f"Failed to connect to MCP SSE server: {e}")
self._connection_params = None
self._connected = False
msg = f"Failed to connect to MCP SSE server: {e}"
raise ValueError(msg) from e
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)."""
try:
return await asyncio.wait_for(
self._connect_to_server(url, headers), timeout=get_settings_service().settings.mcp_server_timeout
)
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
logger.error(f"Failed to connect to MCP SSE server: {e}")
self._connection_params = None
@ -568,7 +595,10 @@ async def update_tools(
return "", [], {}
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
logger.error(f"Failed to connect to MCP server '{server_name}': {e}")
return "", [], {}
# return "", [], {}
msg = f"Failed to connect to MCP server '{server_name}': {e}"
logger.error(msg)
raise ValueError(msg) from e
if not tools or not client or not client._connected:
logger.warning(f"No tools available from MCP server '{server_name}' or connection failed")
@ -598,10 +628,11 @@ async def update_tools(
tool_cache[tool.name] = tool_obj
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
logger.error(f"Failed to create tool '{tool.name}' from server '{server_name}': {e}")
continue
msg = f"Failed to create tool '{tool.name}' from server '{server_name}': {e}"
raise ValueError(msg) from e
logger.info(f"Successfully loaded {len(tool_list)} tools from MCP server '{server_name}'")
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
except (ConnectionError, TimeoutError, OSError, ValueError, AttributeError, AssertionError) as e:
logger.error(f"Unexpected error while updating tools for MCP server '{server_name}': {e}")
return "", [], {}
else:

View file

@ -67,6 +67,7 @@ class MCPToolsComponent(ComponentWithCache):
sse_client: MCPSseClient = MCPSseClient()
tools: list = []
_tool_cache: dict = {}
_last_selected_server: str | None = None # Cache for the last selected server
default_keys: list[str] = [
"code",
"_type",
@ -217,12 +218,14 @@ class MCPToolsComponent(ComponentWithCache):
if len(self.tools) == 0:
try:
self.tools, build_config["mcp_server"]["value"] = await self.update_tool_list()
build_config["tool"]["options"] = [tool.name for tool in self.tools]
except ValueError:
build_config["tool"]["options"] = []
build_config["tool"]["value"] = ""
build_config["tool"]["placeholder"] = "Error on MCP Server"
return build_config
build_config["tool"]["placeholder"] = ""
if field_value == "":
return build_config
tool_obj = None
@ -242,30 +245,61 @@ class MCPToolsComponent(ComponentWithCache):
else:
return build_config
elif field_name == "mcp_server":
try:
# field_value is now a dict {name, config}
mcp_server_value = field_value
self.tools, build_config["mcp_server"]["value"] = await self.update_tool_list(mcp_server_value)
except ValueError:
if not build_config["tools_metadata"]["show"]:
build_config["tool"]["show"] = True
build_config["tool"]["options"] = []
build_config["tool"]["value"] = ""
build_config["tool"]["placeholder"] = "Error on MCP Server"
else:
build_config["tool"]["show"] = False
self.remove_non_default_keys(build_config)
return build_config
build_config["tool"]["placeholder"] = ""
if "tool" in build_config and len(self.tools) > 0 and not build_config["tools_metadata"]["show"]:
build_config["tool"]["show"] = True
build_config["tool"]["options"] = [tool.name for tool in self.tools]
await self._update_tool_config(build_config, build_config["tool"]["value"])
elif "tool" in build_config and len(self.tools) == 0:
self.remove_non_default_keys(build_config)
if not field_value:
build_config["tool"]["show"] = False
build_config["tool"]["options"] = []
build_config["tool"]["value"] = ""
build_config["tool"]["placeholder"] = ""
self.remove_non_default_keys(build_config)
return build_config
current_server_name = field_value.get("name") if isinstance(field_value, dict) else field_value
# To avoid unnecessary updates, only proceed if the server has actually changed
if self._last_selected_server == current_server_name:
return build_config
# Determine if "Tool Mode" is active by checking if the tool dropdown is hidden.
# The check for _last_selected_server handles the initial state where the dropdown is
# hidden by default but we are not yet in "Tool Mode".
is_in_tool_mode = build_config["tools_metadata"]["value"]
self._last_selected_server = current_server_name
self.tools = [] # Clear previous tools
self.remove_non_default_keys(build_config) # Clear previous tool inputs
# Only show the tool dropdown if not in tool_mode
if not is_in_tool_mode:
build_config["tool"]["show"] = True
build_config["tool"]["placeholder"] = "Loading tools..."
build_config["tool"]["options"] = []
build_config["tool"]["value"] = ""
else:
# Keep the tool dropdown hidden if in tool_mode
build_config["tool"]["show"] = False
try:
# Fetch tools for the newly selected server
tools, server_info = await self.update_tool_list(field_value)
build_config["mcp_server"]["value"] = server_info
if tools:
tool_names = [tool.name for tool in tools]
if not is_in_tool_mode:
build_config["tool"]["options"] = tool_names
build_config["tool"]["placeholder"] = "Select a tool"
elif not is_in_tool_mode:
build_config["tool"]["placeholder"] = "No tools found"
except (ValueError, AttributeError, KeyError, TypeError, ConnectionError, TimeoutError) as e:
logger.error(f"Failed to fetch tools for server '{current_server_name}': {e}")
if not is_in_tool_mode:
build_config["tool"]["placeholder"] = "Error fetching tools"
build_config["tool"]["options"] = []
build_config["tool"]["value"] = ""
finally:
# Ensure we don't show inputs for a tool that might no longer be valid
await self._update_tool_config(build_config, "")
elif field_name == "tool_mode":
try:
self.tools, build_config["mcp_server"]["value"] = await self.update_tool_list()
@ -429,4 +463,5 @@ class MCPToolsComponent(ComponentWithCache):
async def _get_tools(self):
"""Get cached tools or update if necessary."""
mcp_server = getattr(self, "mcp_server", None)
return await self.update_tool_list(mcp_server)
tools, _ = await self.update_tool_list(mcp_server)
return tools

View file

@ -92,6 +92,10 @@ class Settings(BaseSettings):
"""The number of seconds to wait before giving up on a lock to released or establishing a connection to the
database."""
mcp_server_timeout: int = 20
"""The number of seconds to wait before giving up on a lock to released or establishing a connection to the
database."""
# sqlite configuration
sqlite_pragmas: dict | None = {"synchronous": "NORMAL", "journal_mode": "WAL"}
"""SQLite pragmas to use when connecting to the database."""

View file

@ -77,7 +77,7 @@ dependencies = [
"validators>=0.34.0",
"networkx>=3.4.2",
"json-repair>=0.30.3",
"mcp>=1.6.0",
"mcp~=1.9.4",
"aiosqlite>=0.20.0",
"greenlet>=3.1.1",
"jsonquerylang>=1.1.1",

View file

@ -82,6 +82,7 @@ export default function Dropdown({
// We should only reset the value if it's not in options and not in filteredOptions
// and not a recently added custom value
if (!options.includes(value) && !filteredOptions.includes(value)) {
if (value) onSelect("", undefined, true);
return null;
}
return value;

View file

@ -25,7 +25,9 @@ export default function McpComponent({
name: server.name,
description:
server.toolsCount === null
? "Loading..."
? server.error
? "Error"
: "Loading..."
: !server.toolsCount
? "No actions found"
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`,

View file

@ -29,8 +29,16 @@ export const useGetMCPServers: useQueryFunctionType<
if (cachedData && Array.isArray(cachedData)) {
const merged = data.map((server) => {
const cached = cachedData.find((s) => s.name === server.name);
return cached && (cached.toolsCount !== null || cached.mode !== null)
? { ...server, toolsCount: cached.toolsCount, mode: cached.mode }
return cached &&
(cached.toolsCount !== null ||
cached.mode !== null ||
cached.error !== null)
? {
...server,
toolsCount: cached.toolsCount,
mode: cached.mode,
error: cached.error,
}
: server;
});
return merged;

View file

@ -14,4 +14,4 @@ export const ENABLE_WIDGET = true;
export const ENABLE_VOICE_ASSISTANT = true;
export const ENABLE_IMAGE_ON_PLAYGROUND = false;
export const ENABLE_MCP = true;
export const ENABLE_MCP_NOTICE = true;
export const ENABLE_MCP_NOTICE = false;

View file

@ -220,7 +220,7 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
return (
<CardsWrapComponent
onFileDrop={handleFileDrop}
onFileDrop={flowType === "mcp" ? undefined : handleFileDrop}
dragMessage={`Drop your ${isEmptyFolder ? "flows or components" : flowType} here`}
>
<div

View file

@ -1,4 +1,5 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -14,6 +15,7 @@ import AddMcpServerModal from "@/modals/addMcpServerModal";
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
import useAlertStore from "@/stores/alertStore";
import { MCPServerInfoType } from "@/types/mcp";
import { cn } from "@/utils/utils";
import { useState } from "react";
export default function MCPServersPage() {
@ -101,11 +103,20 @@ export default function MCPServersPage() {
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{server.name}</span>
<span className="text-mmd text-muted-foreground">
{server.toolsCount === null
? "Loading..."
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`}
</span>
<ShadTooltip content={server.error}>
<span
className={cn(
"cursor-default select-none !text-mmd text-muted-foreground",
server.error && "text-accent-red-foreground",
)}
>
{server.toolsCount === null
? server.error
? "Error"
: "Loading..."
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`}
</span>
</ShadTooltip>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -14,6 +14,7 @@ export type MCPServerInfoType = {
description?: string;
mode: string | null;
toolsCount: number | null;
error?: string;
};
export type MCPServerType = {

5
uv.lock generated
View file

@ -3,6 +3,7 @@ revision = 2
requires-python = ">=3.10, <3.14"
resolution-markers = [
"python_full_version >= '3.13' and sys_platform == 'darwin'",
"python_version < '0'",
"python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version >= '3.12.4' and python_full_version < '3.13' and sys_platform == 'darwin'",
@ -5086,7 +5087,7 @@ requires-dist = [
{ name = "llama-cpp-python", marker = "extra == 'local'", specifier = "~=0.2.0" },
{ name = "markdown", specifier = "==3.7" },
{ name = "markupsafe", specifier = "==3.0.2" },
{ name = "mcp", specifier = ">=0.9.1" },
{ name = "mcp", specifier = ">=1.9.4" },
{ name = "mem0ai", specifier = "==0.1.34" },
{ name = "metal-sdk", specifier = "==2.5.1" },
{ name = "metaphor-python", specifier = "==0.1.23" },
@ -5363,7 +5364,7 @@ requires-dist = [
{ name = "llama-cpp-python", marker = "extra == 'all'", specifier = ">=0.2.0" },
{ name = "llama-cpp-python", marker = "extra == 'local'", specifier = ">=0.2.0" },
{ name = "loguru", specifier = ">=0.7.1,<1.0.0" },
{ name = "mcp", specifier = ">=1.6.0" },
{ name = "mcp", specifier = "~=1.9.4" },
{ name = "multiprocess", specifier = ">=0.70.14,<1.0.0" },
{ name = "nanoid", specifier = ">=2.0.0,<3.0.0" },
{ name = "nest-asyncio", specifier = ">=1.6.0,<2.0.0" },