fix: make MCP display loading states when loading tools, surface errors on MCP, sanitize MCP names (#8792)

* Catch timeout errors on check server

* Make errors propagate from the MCP clients

* Apply timeout error handling and made Server change only trigger a loader on the Tools dropdown

* Add placeholder to ToolsInput on errors

* Updated useEffect to run when nothing is selected

* Added timeout handling to mcp component

* Added placeholder to tools component

* removed unused props

* Added timeout handling on loading of tools on config page

* Fixed key pair input not working

* Set key pair values as empty list

* Surface final error from mcp

* Removed ID from tool mode turning on

* Turn exception on to more places

* Fixed cache on mcp component and make tool mode data not reset

* Added loading placeholder only if there are no data

* Refresh data if placeholder is Loading on tool mode

* Show modal if no tools are available

* Add useEffect to run handleOnNewValue if placeholder is Loading actions...

* Removed checks from toolsTable to run handleOnNewValue

* Sanitized MCP name

* Updated message

* Fixed actions not loading in mcp component

* [autofix.ci] apply automated fixes

* reuse mcp servers

* mypy fixes

* fix: update tool reference in MCPToolsComponent to use field_value

* Added last_updated to backend

* get latest version of node and compare last_updated before returning post template value

* assign last updated and only set node class if newTemplate exists

* Adds type

* Removed timeout from backend to frontend

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: phact <estevezsebastian@gmail.com>
Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
Lucas Oliveira 2025-07-02 12:31:48 -03:00 committed by GitHub
commit 6460e23bfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 798 additions and 316 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>=1.9.4",
"mcp>=1.10.1",
"uv>=0.5.7",
"scipy>=1.14.1",
"ag2>=0.1.0",

View file

@ -21,7 +21,7 @@ from langflow.api.utils import CurrentActiveMCPUser
from langflow.api.v1.endpoints import simple_run_flow
from langflow.api.v1.schemas import SimplifiedAPIRequest
from langflow.base.mcp.constants import MAX_MCP_TOOL_NAME_LENGTH
from langflow.base.mcp.util import get_flow_snake_case
from langflow.base.mcp.util import get_flow_snake_case, sanitize_mcp_name
from langflow.helpers.flow import json_schema_from_flow
from langflow.schema.message import Message
from langflow.services.database.models.flow.model import Flow
@ -186,7 +186,7 @@ async def handle_list_tools():
if flow.user_id is None:
continue
base_name = "_".join(flow.name.lower().split())
base_name = sanitize_mcp_name(flow.name)
name = base_name[:MAX_MCP_TOOL_NAME_LENGTH]
if name in existing_names:
i = 1

View file

@ -32,7 +32,7 @@ from langflow.api.v1.mcp import (
)
from langflow.api.v1.schemas import MCPInstallRequest, MCPSettings, SimplifiedAPIRequest
from langflow.base.mcp.constants import MAX_MCP_SERVER_NAME_LENGTH, MAX_MCP_TOOL_NAME_LENGTH
from langflow.base.mcp.util import get_flow_snake_case, get_unique_name
from langflow.base.mcp.util import get_flow_snake_case, get_unique_name, sanitize_mcp_name
from langflow.helpers.flow import json_schema_from_flow
from langflow.schema.message import Message
from langflow.services.database.models import Flow, Folder
@ -96,10 +96,10 @@ async def list_project_tools(
continue
# Format the flow name according to MCP conventions (snake_case)
flow_name = "_".join(flow.name.lower().split())
flow_name = sanitize_mcp_name(flow.name)
# Use action_name and action_description if available, otherwise use defaults
name = flow.action_name or flow_name
name = sanitize_mcp_name(flow.action_name) if flow.action_name else flow_name
description = flow.action_description or (
flow.description if flow.description else f"Tool generated from flow: {flow_name}"
)
@ -395,14 +395,14 @@ async def install_mcp_config(
# Create the MCP configuration
mcp_config = {
"mcpServers": {
f"lf-{name.lower().replace(' ', '_')[: (MAX_MCP_SERVER_NAME_LENGTH - 4)]}": {
f"lf-{sanitize_mcp_name(name)[: (MAX_MCP_SERVER_NAME_LENGTH - 4)]}": {
"command": command,
"args": args,
}
}
}
server_name = f"lf-{name.lower().replace(' ', '_')[: (MAX_MCP_SERVER_NAME_LENGTH - 4)]}"
server_name = f"lf-{sanitize_mcp_name(name)[: (MAX_MCP_SERVER_NAME_LENGTH - 4)]}"
logger.debug("Installing MCP config for project: %s (server name: %s)", project.name, server_name)
# Determine the config file path based on the client and OS
@ -518,7 +518,7 @@ async def check_installed_mcp_servers(
# Project server name pattern (must match the logic in install function)
name = project.name
name = NEW_FOLDER_NAME if name == DEFAULT_FOLDER_NAME else name
project_server_name = f"lf-{name.lower().replace(' ', '_')[: (MAX_MCP_SERVER_NAME_LENGTH - 4)]}"
project_server_name = f"lf-{sanitize_mcp_name(name)[: (MAX_MCP_SERVER_NAME_LENGTH - 4)]}"
logger.debug(
"Checking for installed MCP servers for project: %s (server name: %s)", project.name, project_server_name
@ -670,7 +670,9 @@ class ProjectMCPServer:
continue
# Use action_name if available, otherwise construct from flow name
base_name = flow.action_name or "_".join(flow.name.lower().split())
base_name = (
sanitize_mcp_name(flow.action_name) if flow.action_name else sanitize_mcp_name(flow.name)
)
name = get_unique_name(base_name, MAX_MCP_TOOL_NAME_LENGTH, existing_names)
# Use action_description if available, otherwise use defaults

View file

@ -128,10 +128,14 @@ async def get_servers(
# 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:
except ConnectionError 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 (TimeoutError, asyncio.TimeoutError) as e:
# Timeout errors
logger.error(f"Timeout error for server {server_name}: {e}")
server_info["error"] = "Timeout when checking server tools"
except OSError as e:
# System-level errors (process execution, file access)
logger.error(f"System error for server {server_name}: {e}")
@ -149,24 +153,21 @@ async def get_servers(
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}")
if hasattr(underlying_error, "exceptions"):
logger.error(
f"Error checking server {server_name}: {underlying_error}, {underlying_error.exceptions}"
)
underlying_error = underlying_error.exceptions[0]
else:
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_with_timeout(server) for server in server_list["mcpServers"]]
tasks = [check_server(server) for server in server_list["mcpServers"]]
return await asyncio.gather(*tasks, return_exceptions=True)

View file

@ -1,7 +1,9 @@
import asyncio
import os
import platform
import re
import shutil
import unicodedata
from collections.abc import Awaitable, Callable
from typing import Any
from urllib.parse import urlparse
@ -22,6 +24,77 @@ HTTP_ERROR_STATUS_CODE = httpx_codes.BAD_REQUEST # HTTP status code for client
NULLABLE_TYPE_LENGTH = 2 # Number of types in a nullable union (the type itself + null)
def sanitize_mcp_name(name: str, max_length: int = 46) -> str:
"""Sanitize a name for MCP usage by removing emojis, diacritics, and special characters.
Args:
name: The original name to sanitize
max_length: Maximum length for the sanitized name
Returns:
A sanitized name containing only letters, numbers, hyphens, and underscores
"""
if not name or not name.strip():
return ""
# Remove emojis using regex pattern
emoji_pattern = re.compile(
"["
"\U0001f600-\U0001f64f" # emoticons
"\U0001f300-\U0001f5ff" # symbols & pictographs
"\U0001f680-\U0001f6ff" # transport & map symbols
"\U0001f1e0-\U0001f1ff" # flags (iOS)
"\U00002500-\U00002bef" # chinese char
"\U00002702-\U000027b0"
"\U00002702-\U000027b0"
"\U000024c2-\U0001f251"
"\U0001f926-\U0001f937"
"\U00010000-\U0010ffff"
"\u2640-\u2642"
"\u2600-\u2b55"
"\u200d"
"\u23cf"
"\u23e9"
"\u231a"
"\ufe0f" # dingbats
"\u3030"
"]+",
flags=re.UNICODE,
)
# Remove emojis
name = emoji_pattern.sub("", name)
# Normalize unicode characters to remove diacritics
name = unicodedata.normalize("NFD", name)
name = "".join(char for char in name if unicodedata.category(char) != "Mn")
# Replace spaces and special characters with underscores
name = re.sub(r"[^\w\s-]", "", name) # Keep only word chars, spaces, and hyphens
name = re.sub(r"[-\s]+", "_", name) # Replace spaces and hyphens with underscores
name = re.sub(r"_+", "_", name) # Collapse multiple underscores
# Remove leading/trailing underscores
name = name.strip("_")
# Ensure it starts with a letter or underscore (not a number)
if name and name[0].isdigit():
name = f"_{name}"
# Convert to lowercase
name = name.lower()
# Truncate to max length
if len(name) > max_length:
name = name[:max_length].rstrip("_")
# If empty after sanitization, provide a default
if not name:
name = "unnamed"
return name
def create_tool_coroutine(tool_name: str, arg_schema: type[BaseModel], client) -> Callable[..., Awaitable]:
async def tool_coroutine(*args, **kwargs):
# Get field names from the model (preserving order)
@ -101,7 +174,11 @@ async def get_flow_snake_case(flow_name: str, user_id: str, session, is_action:
flows = (await session.exec(stmt)).all()
for flow in flows:
this_flow_name = flow.action_name if is_action and flow.action_name else "_".join(flow.name.lower().split())
if is_action and flow.action_name:
this_flow_name = sanitize_mcp_name(flow.action_name)
else:
this_flow_name = sanitize_mcp_name(flow.name)
if this_flow_name == flow_name:
return flow
return None
@ -295,16 +372,179 @@ async def _validate_connection_params(mode: str, command: str | None = None, url
raise ValueError(msg)
class MCPStdioClient:
class MCPSessionManager:
"""Manages persistent MCP sessions with proper context manager lifecycle."""
def __init__(self):
self.sessions = {} # context_id -> session_info
self._background_tasks = set() # Keep references to background tasks
async def get_session(self, context_id: str, connection_params, transport_type: str):
"""Get or create a persistent session."""
if context_id in self.sessions:
session_info = self.sessions[context_id]
# Check if session and background task are still alive
try:
session = session_info["session"]
task = session_info["task"]
if (
not task.done()
and hasattr(session, "_closed")
and not session._closed
and hasattr(session, "_write_stream")
and hasattr(session._write_stream, "_closed")
and not session._write_stream._closed
):
return session
except Exception: # noqa: BLE001
msg = f"Session for context_id {context_id} is dead"
logger.info(msg)
# Session is dead, clean it up
await self._cleanup_session(context_id)
# Create new session
if transport_type == "stdio":
return await self._create_stdio_session(context_id, connection_params)
if transport_type == "sse":
return await self._create_sse_session(context_id, connection_params)
msg = f"Unknown transport type: {transport_type}"
raise ValueError(msg)
async def _create_stdio_session(self, context_id: str, connection_params):
"""Create a new stdio session as a background task to avoid context issues."""
import asyncio
from mcp.client.stdio import stdio_client
# Create a future to get the session
session_future: asyncio.Future[ClientSession] = asyncio.Future()
async def session_task():
"""Background task that keeps the session alive."""
try:
async with stdio_client(connection_params) as (read, write):
session = ClientSession(read, write)
async with session:
await session.initialize()
# Signal that session is ready
session_future.set_result(session)
# Keep the session alive until cancelled
import anyio
event = anyio.Event()
try:
await event.wait()
except asyncio.CancelledError:
# Session is being shut down
msg = "Message is shutting down"
logger.info(msg)
except Exception as e: # noqa: BLE001
if not session_future.done():
session_future.set_exception(e)
# 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
session = await session_future
# Store session info
self.sessions[context_id] = {"session": session, "task": task, "type": "stdio"}
return session
async def _create_sse_session(self, context_id: str, connection_params):
"""Create a new SSE session as a background task to avoid context issues."""
import asyncio
from mcp.client.sse import sse_client
# Create a future to get the session
session_future: asyncio.Future[ClientSession] = asyncio.Future()
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)
# Keep the session alive until cancelled
import anyio
event = anyio.Event()
try:
await event.wait()
except asyncio.CancelledError:
# Session is being shut down
msg = "Message is shutting down"
logger.info(msg)
except Exception as e: # noqa: BLE001
if not session_future.done():
session_future.set_exception(e)
# 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
session = await session_future
# Store session info
self.sessions[context_id] = {"session": session, "task": task, "type": "sse"}
return session
async def _cleanup_session(self, context_id: str):
"""Clean up a session by cancelling its background task."""
if context_id not in self.sessions:
return
session_info = self.sessions[context_id]
try:
# Cancel the background task which will properly close the session
if "task" in session_info:
task = session_info["task"]
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
logger.info(f"Issue cancelling task for context_id {context_id}")
except Exception as e: # noqa: BLE001
logger.info(f"issue cleaning up mcp session: {e}")
finally:
del self.sessions[context_id]
async def cleanup_all(self):
"""Clean up all sessions."""
for context_id in list(self.sessions.keys()):
await self._cleanup_session(context_id)
class MCPStdioClient:
def __init__(self, component_cache=None):
self.session: ClientSession | None = None
self._connection_params = None
self._connected = False
self._session_context: str | None = None
self._component_cache = component_cache
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
command = command_str.split(" ")
env_data: dict[str, str] = {"DEBUG": "true", "PATH": os.environ["PATH"], **(env or {})}
@ -328,39 +568,101 @@ class MCPStdioClient:
# Store connection parameters for later use in run_tool
self._connection_params = server_params
try:
async with stdio_client(server_params) as (read, write), ClientSession(read, write) as session:
await session.initialize()
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 stdio server: {e}")
self._connection_params = None
self._connected = False
return []
# If no session context is set, create a default one
if not self._session_context:
# Generate a fallback context based on connection parameters
import uuid
param_hash = uuid.uuid4().hex[:8]
self._session_context = f"default_{param_hash}"
# Get or create a persistent session
session = await self._get_or_create_session()
response = await session.list_tools()
self._connected = True
return response.tools
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)."""
return await asyncio.wait_for(
self._connect_to_server(command_str, env), timeout=get_settings_service().settings.mcp_server_timeout
)
def set_session_context(self, context_id: str):
"""Set the session context (e.g., flow_id + user_id + session_id)."""
self._session_context = context_id
def _get_session_manager(self) -> MCPSessionManager:
"""Get or create session manager from component cache."""
if not self._component_cache:
# Fallback to instance-level session manager if no cache
if not hasattr(self, "_session_manager"):
self._session_manager = MCPSessionManager()
return self._session_manager
from langflow.services.cache.utils import CacheMiss
session_manager = self._component_cache.get("mcp_session_manager")
if isinstance(session_manager, CacheMiss):
session_manager = MCPSessionManager()
self._component_cache.set("mcp_session_manager", session_manager)
return session_manager
async def _get_or_create_session(self) -> ClientSession:
"""Get or create a persistent session for the current context."""
if not self._session_context or not self._connection_params:
msg = "Session context and connection params must be set"
raise ValueError(msg)
# Use cached session manager to get/create persistent session
session_manager = self._get_session_manager()
return await session_manager.get_session(self._session_context, self._connection_params, "stdio")
async def _create_fresh_session(self) -> ClientSession:
"""Create a fresh session using the async context manager pattern."""
from mcp.client.stdio import stdio_client
# This creates a temporary session that will be closed when the context exits
# We need to use this within an async context
stdio_ctx = stdio_client(self._connection_params)
read, write = await stdio_ctx.__aenter__()
session = ClientSession(read, write)
await session.__aenter__()
await session.initialize()
# Store the context managers so they can be cleaned up later
session._stdio_ctx = stdio_ctx
session._stdio_read = read
session._stdio_write = write
return session
async def _cleanup_session(self, session: ClientSession):
"""Clean up a session and its associated resources."""
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
await session.__aexit__(None, None, None)
except Exception as e: # noqa: BLE001
logger.info(f"issue cleaning up mcp session: {e}")
try:
if hasattr(session, "_stdio_ctx"):
await session._stdio_ctx.__aexit__(None, None, None)
except Exception as e: # noqa: BLE001
logger.info(f"issue cleaning up mcp session: {e}")
async def disconnect(self):
"""Properly close the connection and clean up resources."""
# Clean up session using session manager
if self._session_context:
session_manager = self._get_session_manager()
await session_manager._cleanup_session(self._session_context)
self.session = None
self._connection_params = None
self._connected = False
self._session_context = None
async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""Run a tool with the given arguments.
"""Run a tool with the given arguments using context-specific session.
Args:
tool_name: Name of the tool to run
@ -376,16 +678,26 @@ class MCPStdioClient:
msg = "Session not initialized or disconnected. Call connect_to_server first."
raise ValueError(msg)
try:
from mcp.client.stdio import stdio_client
# If no session context is set, create a default one
if not self._session_context:
# Generate a fallback context based on connection parameters
import uuid
param_hash = uuid.uuid4().hex[:8]
self._session_context = f"default_{param_hash}"
try:
# Get or create persistent session
session = await self._get_or_create_session()
return await session.call_tool(tool_name, arguments=arguments)
async with stdio_client(self._connection_params) as (read, write), ClientSession(read, write) as session:
await session.initialize()
return await session.call_tool(tool_name, arguments=arguments)
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
msg = f"Failed to run tool '{tool_name}': {e}"
logger.error(msg)
# Mark as disconnected on error
# Clean up failed session from cache
if self._session_context and self._component_cache:
cache_key = f"mcp_session_stdio_{self._session_context}"
self._component_cache.delete(cache_key)
self._connected = False
raise ValueError(msg) from e
@ -397,10 +709,28 @@ class MCPStdioClient:
class MCPSseClient:
def __init__(self):
def __init__(self, component_cache=None):
self.session: ClientSession | None = None
self._connection_params = None
self._connected = False
self._session_context: str | None = None
self._component_cache = component_cache
def _get_session_manager(self) -> MCPSessionManager:
"""Get or create session manager from component cache."""
if not self._component_cache:
# Fallback to instance-level session manager if no cache
if not hasattr(self, "_session_manager"):
self._session_manager = MCPSessionManager()
return self._session_manager
from langflow.services.cache.utils import CacheMiss
session_manager = self._component_cache.get("mcp_session_manager")
if isinstance(session_manager, CacheMiss):
session_manager = MCPSessionManager()
self._component_cache.set("mcp_session_manager", session_manager)
return session_manager
async def validate_url(self, url: str | None) -> tuple[bool, str]:
"""Validate the SSE URL before attempting connection."""
@ -447,8 +777,6 @@ class MCPSseClient:
sse_read_timeout_seconds: int = 30,
) -> list[StructuredTool]:
"""Connect to MCP server using SSE transport (SDK style)."""
from mcp.client.sse import sse_client
if headers is None:
headers = {}
if url is None:
@ -469,42 +797,54 @@ class MCPSseClient:
"sse_read_timeout_seconds": sse_read_timeout_seconds,
}
try:
async with (
sse_client(url, headers, timeout_seconds, sse_read_timeout_seconds) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
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
# If no session context is set, create a default one
if not self._session_context:
# Generate a fallback context based on connection parameters
import uuid
param_hash = uuid.uuid4().hex[:8]
self._session_context = f"default_sse_{param_hash}"
# Get or create a persistent session
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)."""
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
self._connected = False
return []
return await asyncio.wait_for(
self._connect_to_server(url, headers), timeout=get_settings_service().settings.mcp_server_timeout
)
def set_session_context(self, context_id: str):
"""Set the session context (e.g., flow_id + user_id + session_id)."""
self._session_context = context_id
async def _get_or_create_session(self) -> ClientSession:
"""Get or create a persistent session for the current context."""
if not self._session_context or not self._connection_params:
msg = "Session context and params must be set"
raise ValueError(msg)
# Use cached session manager to get/create persistent session
session_manager = self._get_session_manager()
return await session_manager.get_session(self._session_context, self._connection_params, "sse")
async def disconnect(self):
"""Properly close the connection and clean up resources."""
# Clean up session using session manager
if self._session_context:
session_manager = self._get_session_manager()
await session_manager._cleanup_session(self._session_context)
self.session = None
self._connection_params = None
self._connected = False
self._session_context = None
async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""Run a tool with the given arguments.
"""Run a tool with the given arguments using context-specific session.
Args:
tool_name: Name of the tool to run
@ -520,22 +860,26 @@ class MCPSseClient:
msg = "Session not initialized or disconnected. Call connect_to_server first."
raise ValueError(msg)
try:
from mcp.client.sse import sse_client
# If no session context is set, create a default one
if not self._session_context:
# Generate a fallback context based on connection parameters
import uuid
param_hash = uuid.uuid4().hex[:8]
self._session_context = f"default_sse_{param_hash}"
try:
# Get or create persistent session
session = await self._get_or_create_session()
return await session.call_tool(tool_name, arguments=arguments)
params = self._connection_params
async with (
sse_client(
params["url"], params["headers"], params["timeout_seconds"], params["sse_read_timeout_seconds"]
) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
return await session.call_tool(tool_name, arguments=arguments)
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
msg = f"Failed to run tool '{tool_name}': {e}"
logger.error(msg)
# Mark as disconnected on error
# Clean up failed session from cache
if self._session_context and self._component_cache:
cache_key = f"mcp_session_sse_{self._session_context}"
self._component_cache.delete(cache_key)
self._connected = False
raise ValueError(msg) from e
@ -562,78 +906,66 @@ async def update_tools(
if mcp_sse_client is None:
mcp_sse_client = MCPSseClient()
# Fetch server config from backend
mode = "Stdio" if "command" in server_config else "SSE" if "url" in server_config else ""
command = server_config.get("command", "")
url = server_config.get("url", "")
tools = []
headers = _process_headers(server_config.get("headers", {}))
try:
# Fetch server config from backend
mode = "Stdio" if "command" in server_config else "SSE" if "url" in server_config else ""
command = server_config.get("command", "")
url = server_config.get("url", "")
tools = []
headers = _process_headers(server_config.get("headers", {}))
await _validate_connection_params(mode, command, url)
except ValueError as e:
logger.error(f"Invalid MCP server configuration for '{server_name}': {e}")
raise
try:
await _validate_connection_params(mode, command, url)
except ValueError as e:
logger.error(f"Invalid MCP server configuration for '{server_name}': {e}")
return "", [], {}
# Determine connection type and parameters
client: MCPStdioClient | MCPSseClient | None = None
if mode == "Stdio":
# Stdio connection
args = server_config.get("args", [])
env = server_config.get("env", {})
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
else:
logger.error(f"Invalid MCP server mode for '{server_name}': {mode}")
return "", [], {}
# Determine connection type and parameters
client: MCPStdioClient | MCPSseClient | None = None
if not tools or not client or not client._connected:
logger.warning(f"No tools available from MCP server '{server_name}' or connection failed")
return "", [], {}
tool_list = []
tool_cache: dict[str, StructuredTool] = {}
for tool in tools:
if not tool or not hasattr(tool, "name"):
continue
try:
if mode == "Stdio":
# Stdio connection
args = server_config.get("args", [])
env = server_config.get("env", {})
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
else:
logger.error(f"Invalid MCP server mode for '{server_name}': {mode}")
return "", [], {}
args_schema = create_input_schema_from_json_schema(tool.inputSchema)
if not args_schema:
logger.warning(f"Could not create schema for tool '{tool.name}' from server '{server_name}'")
continue
tool_obj = StructuredTool(
name=tool.name,
description=tool.description or "",
args_schema=args_schema,
func=create_tool_func(tool.name, args_schema, client),
coroutine=create_tool_coroutine(tool.name, args_schema, client),
tags=[tool.name],
metadata={"server_name": server_name},
)
tool_list.append(tool_obj)
tool_cache[tool.name] = tool_obj
except (ConnectionError, TimeoutError, OSError, ValueError) as e:
logger.error(f"Failed to connect to MCP server '{server_name}': {e}")
# return "", [], {}
msg = f"Failed to connect to MCP server '{server_name}': {e}"
logger.error(msg)
logger.error(f"Failed to create tool '{tool.name}' from server '{server_name}': {e}")
msg = f"Failed to create tool '{tool.name}' from server '{server_name}': {e}"
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")
return "", [], {}
tool_list = []
tool_cache: dict[str, StructuredTool] = {}
for tool in tools:
if not tool or not hasattr(tool, "name"):
continue
try:
args_schema = create_input_schema_from_json_schema(tool.inputSchema)
if not args_schema:
logger.warning(f"Could not create schema for tool '{tool.name}' from server '{server_name}'")
continue
tool_obj = StructuredTool(
name=tool.name,
description=tool.description or "",
args_schema=args_schema,
func=create_tool_func(tool.name, args_schema, client),
coroutine=create_tool_coroutine(tool.name, args_schema, client),
tags=[tool.name],
metadata={"server_name": server_name},
)
tool_list.append(tool_obj)
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}")
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, AttributeError, AssertionError) as e:
logger.error(f"Unexpected error while updating tools for MCP server '{server_name}': {e}")
return "", [], {}
else:
return mode, tool_list, tool_cache
logger.info(f"Successfully loaded {len(tool_list)} tools from MCP server '{server_name}'")
return mode, tool_list, tool_cache

View file

@ -1,6 +1,10 @@
import asyncio
import re
import uuid
from typing import Any
from langchain_core.tools import StructuredTool
from langflow.api.v2.mcp import get_server
from langflow.base.mcp.util import (
MCPSseClient,
@ -10,13 +14,12 @@ from langflow.base.mcp.util import (
)
from langflow.custom.custom_component.component_with_cache import ComponentWithCache
from langflow.inputs.inputs import InputTypes
from langflow.io import DropdownInput, McpInput, MessageTextInput, Output # Import McpInput from langflow.io
from langflow.io import DropdownInput, McpInput, MessageTextInput, Output
from langflow.io.schema import flatten_schema, schema_to_langflow_inputs
from langflow.logging import logger
from langflow.schema.dataframe import DataFrame
from langflow.schema.message import Message
from langflow.services.auth.utils import create_user_longterm_token
from langflow.services.cache.utils import CacheMiss
# Import get_server from the backend API
from langflow.services.database.models.user.crud import get_user_by_id
@ -63,11 +66,23 @@ def maybe_unflatten_dict(flat: dict[str, Any]) -> dict[str, Any]:
class MCPToolsComponent(ComponentWithCache):
schema_inputs: list = []
stdio_client: MCPStdioClient = MCPStdioClient()
sse_client: MCPSseClient = MCPSseClient()
tools: list = []
tools: list[StructuredTool] = []
_not_load_actions: bool = False
_tool_cache: dict = {}
_last_selected_server: str | None = None # Cache for the last selected server
def __init__(self, **data) -> None:
super().__init__(**data)
# Initialize cache keys to avoid CacheMiss when accessing them
if "servers" not in self._shared_component_cache:
self._shared_component_cache["servers"] = {}
if "last_selected_server" not in self._shared_component_cache:
self._shared_component_cache["last_selected_server"] = ""
# 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)
default_keys: list[str] = [
"code",
"_type",
@ -154,8 +169,8 @@ class MCPToolsComponent(ComponentWithCache):
return [], {"name": server_name, "config": server_config_from_value}
# Use shared cache if available
cached = self._shared_component_cache.get(server_name)
if not isinstance(cached, CacheMiss):
cached = self._shared_component_cache["servers"].get(server_name)
if cached is not None:
self.tools = cached["tools"]
self.tool_names = cached["tool_names"]
self._tool_cache = cached["tool_cache"]
@ -195,16 +210,18 @@ class MCPToolsComponent(ComponentWithCache):
self._tool_cache = tool_cache
self.tools = tool_list
# Cache the result using shared cache
self._shared_component_cache.set(
server_name,
{
"tools": tool_list,
"tool_names": self.tool_names,
"tool_cache": tool_cache,
"config": server_config,
},
)
self._shared_component_cache["servers"][server_name] = {
"tools": tool_list,
"tool_names": self.tool_names,
"tool_cache": tool_cache,
"config": server_config,
}
return tool_list, {"name": server_name, "config": server_config}
except (TimeoutError, asyncio.TimeoutError) as e:
msg = f"Timeout updating tool list: {e!s}"
logger.exception(msg)
raise TimeoutError(msg) from e
except Exception as e:
msg = f"Error updating tool list: {e!s}"
logger.exception(msg)
@ -219,22 +236,35 @@ class MCPToolsComponent(ComponentWithCache):
try:
self.tools, build_config["mcp_server"]["value"] = await self.update_tool_list()
build_config["tool"]["options"] = [tool.name for tool in self.tools]
build_config["tool"]["placeholder"] = "Select a tool"
except (TimeoutError, asyncio.TimeoutError) as e:
msg = f"Timeout updating tool list: {e!s}"
logger.exception(msg)
if not build_config["tools_metadata"]["show"]:
build_config["tool"]["show"] = True
build_config["tool"]["options"] = []
build_config["tool"]["value"] = ""
build_config["tool"]["placeholder"] = "Timeout on MCP server"
else:
build_config["tool"]["show"] = False
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 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
if field_value == "":
return build_config
tool_obj = None
for tool in self.tools:
if tool.name == self.tool:
if tool.name == field_value:
tool_obj = tool
break
if tool_obj is None:
msg = f"Tool {self.tool} not found in available tools: {self.tools}"
msg = f"Tool {field_value} not found in available tools: {self.tools}"
logger.warning(msg)
return build_config
await self._update_tool_config(build_config, field_value)
@ -254,71 +284,63 @@ class MCPToolsComponent(ComponentWithCache):
return build_config
current_server_name = field_value.get("name") if isinstance(field_value, dict) else field_value
_last_selected_server = self._shared_component_cache.get("last_selected_server") or ""
# To avoid unnecessary updates, only proceed if the server has actually changed
if self._last_selected_server == current_server_name:
if (_last_selected_server in (current_server_name, "")) and build_config["tool"]["show"]:
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
is_in_tool_mode = build_config["tools_metadata"]["show"]
self._shared_component_cache.set("last_selected_server", current_server_name)
# Check if tools are already cached for this server before clearing
cached_tools = None
if current_server_name:
cached = self._shared_component_cache["servers"].get(current_server_name)
if cached is not None:
cached_tools = cached["tools"]
self.tools = cached_tools
self.tool_names = cached["tool_names"]
self._tool_cache = cached["tool_cache"]
# Only clear tools if we don't have cached tools for the current server
if not cached_tools:
self.tools = [] # Clear previous tools only if no cache
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"] = ""
if cached_tools:
# Use cached tools to populate options immediately
build_config["tool"]["options"] = [tool.name for tool in cached_tools]
build_config["tool"]["placeholder"] = "Select a tool"
else:
# Show loading state only when we need to fetch tools
build_config["tool"]["placeholder"] = "Loading tools..."
build_config["tool"]["options"] = []
build_config["tool"]["value"] = uuid.uuid4()
else:
# Keep the tool dropdown hidden if in tool_mode
self._not_load_actions = True
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()
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
build_config["tool"]["placeholder"] = ""
build_config["tool"]["show"] = not field_value
for key, value in list(build_config.items()):
if key not in self.default_keys and isinstance(value, dict) and "show" in value:
build_config[key]["show"] = not field_value
if not field_value:
build_config["tool"]["options"] = [tool.name for tool in self.tools]
await self._update_tool_config(build_config, build_config["tool"]["value"])
self.remove_non_default_keys(build_config)
self.tool = build_config["tool"]["value"]
if field_value:
self._not_load_actions = True
else:
build_config["tool"]["value"] = uuid.uuid4()
build_config["tool"]["options"] = []
build_config["tool"]["show"] = True
build_config["tool"]["placeholder"] = "Loading tools..."
elif field_name == "tools_metadata":
self._not_load_actions = False
except Exception as e:
msg = f"Error in update_build_config: {e!s}"
@ -434,6 +456,12 @@ class MCPToolsComponent(ComponentWithCache):
try:
self.tools, _ = await self.update_tool_list()
if self.tool != "":
# Set session context for persistent MCP sessions using Langflow session ID
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)
exec_tool = self._tool_cache[self.tool]
tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool]
kwargs = {}
@ -460,8 +488,25 @@ class MCPToolsComponent(ComponentWithCache):
logger.exception(msg)
raise ValueError(msg) from e
def _get_session_context(self) -> str | None:
"""Get the Langflow session ID for MCP session caching."""
# Try to get session ID from the component's execution context
if hasattr(self, "graph") and hasattr(self.graph, "session_id"):
session_id = self.graph.session_id
# Include server name to ensure different servers get different sessions
server_name = ""
mcp_server = getattr(self, "mcp_server", None)
if isinstance(mcp_server, dict):
server_name = mcp_server.get("name", "")
elif mcp_server:
server_name = str(mcp_server)
return f"{session_id}_{server_name}" if session_id else None
return None
async def _get_tools(self):
"""Get cached tools or update if necessary."""
mcp_server = getattr(self, "mcp_server", None)
tools, _ = await self.update_tool_list(mcp_server)
return tools
if not self._not_load_actions:
tools, _ = await self.update_tool_list(mcp_server)
return tools
return []

View file

@ -1294,7 +1294,20 @@ class Component(CustomComponent):
}
async def _build_tools_metadata_input(self):
tools = await self._get_tools()
try:
from langflow.io import ToolsInput
except ImportError as e:
msg = "Failed to import ToolsInput from langflow.io"
raise ImportError(msg) from e
placeholder = None
tools = []
try:
tools = await self._get_tools()
placeholder = "Loading actions..." if len(tools) == 0 else ""
except (TimeoutError, asyncio.TimeoutError):
placeholder = "Timeout loading actions"
except (ConnectionError, OSError, ValueError):
placeholder = "Error loading actions"
# Always use the latest tool data
tool_data = [self._build_tool_data(tool) for tool in tools]
# print(tool_data)
@ -1322,14 +1335,9 @@ class Component(CustomComponent):
item["status"] = any(enabled_name in [item["name"], *item["tags"]] for enabled_name in enabled)
self.tools_metadata = tool_data
try:
from langflow.io import ToolsInput
except ImportError as e:
msg = "Failed to import ToolsInput from langflow.io"
raise ImportError(msg) from e
return ToolsInput(
name=TOOLS_METADATA_INPUT_NAME,
placeholder=placeholder,
display_name="Actions",
info=TOOLS_METADATA_INFO,
value=tool_data,

View file

@ -67,6 +67,7 @@ class CustomComponentFrontendNode(FrontendNode):
)
description: str | None = None
base_classes: list[str] = []
last_updated: str | None = None
class ComponentFrontendNode(FrontendNode):

View file

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

View file

@ -59,14 +59,15 @@ export const mutateTemplate = async (
newTemplate.outputs ?? [],
);
newNode.tool_mode = toolMode ?? node.tool_mode;
}
try {
setNodeClass(newNode);
} catch (e) {
if (e instanceof Error && e.message === "Node not found") {
console.log("Node not found");
} else {
throw e;
newNode.last_updated = newTemplate.last_updated;
try {
setNodeClass(newNode);
} catch (e) {
if (e instanceof Error && e.message === "Node not found") {
console.log("Node not found");
} else {
throw e;
}
}
}
callback?.();

View file

@ -15,6 +15,7 @@ export default function ToolsComponent({
id = "",
handleOnNewValue,
isAction = false,
placeholder,
button_description,
title,
icon,
@ -46,18 +47,17 @@ export default function ToolsComponent({
disabled && "cursor-not-allowed",
)}
>
{value && (
<ToolsModal
open={isModalOpen}
setOpen={setIsModalOpen}
isAction={isAction}
description={description}
rows={value}
handleOnNewValue={handleOnNewValue}
title={title}
icon={icon}
/>
)}
<ToolsModal
open={isModalOpen}
placeholder={placeholder || ""}
setOpen={setIsModalOpen}
isAction={isAction}
description={description}
rows={value || []}
handleOnNewValue={handleOnNewValue}
title={title}
icon={icon}
/>
<div
className="relative flex w-full items-center gap-3"
data-testid={"div-" + id}
@ -133,7 +133,10 @@ export default function ToolsComponent({
onClick={() => setIsModalOpen(true)}
>
<span>
{value.length === 0 ? "No actions available" : "Select actions"}
{placeholder ||
(value.length === 0
? "No actions available"
: "Select actions")}
</span>
</Button>
)}

View file

@ -26,7 +26,9 @@ export default function McpComponent({
description:
server.toolsCount === null
? server.error
? "Error"
? server.error.startsWith("Timeout")
? "Timeout"
: "Error"
: "Loading..."
: !server.toolsCount
? "No actions found"

View file

@ -1,3 +1,4 @@
import useFlowStore from "@/stores/flowStore";
import {
APIClassType,
ResponseErrorDetailAPI,
@ -26,6 +27,7 @@ export const usePostTemplateValue: useMutationFunctionType<
ResponseErrorDetailAPI
> = ({ parameterId, nodeId, node }, options?) => {
const { mutate } = UseRequestProcessor();
const getNode = useFlowStore((state) => state.getNode);
const postTemplateValueFn = async (
payload: IPostTemplateValue,
@ -33,6 +35,7 @@ export const usePostTemplateValue: useMutationFunctionType<
const template = node.template;
if (!template) return;
const lastUpdated = new Date().toISOString();
const response = await api.post<APIClassType>(
getURL("CUSTOM_COMPONENT", { update: "update" }),
{
@ -43,8 +46,19 @@ export const usePostTemplateValue: useMutationFunctionType<
tool_mode: payload.tool_mode,
},
);
const newTemplate = response.data;
newTemplate.last_updated = lastUpdated;
const newNode = getNode(nodeId)?.data?.node as APIClassType | undefined;
return response.data;
if (
!newNode?.last_updated ||
!newTemplate.last_updated ||
Date.parse(newNode.last_updated) < Date.parse(newTemplate.last_updated)
) {
return newTemplate;
}
return undefined;
};
const mutation: UseMutationResult<

View file

@ -1,5 +1,5 @@
import _ from "lodash";
import { useRef } from "react";
import { useEffect, useState } from "react";
import IconComponent from "../../../../../components/common/genericIconComponent";
import { Input } from "../../../../../components/ui/input";
import { classNames } from "../../../../../utils/utils";
@ -23,27 +23,38 @@ const IOKeyPairInput = ({
return Array.isArray(value) ? value : [value];
};
const ref = useRef<any>([]);
ref.current =
!value || value?.length === 0 ? [{ "": "" }] : checkValueType(value);
const [currentData, setCurrentData] = useState<any[]>(() => {
return !value || value?.length === 0 ? [{ "": "" }] : checkValueType(value);
});
// Update internal state when external value changes
useEffect(() => {
const newData =
!value || value?.length === 0 ? [{ "": "" }] : checkValueType(value);
setCurrentData(newData);
}, [value]);
const handleChangeKey = (event, idx) => {
const oldKey = Object.keys(ref.current[idx])[0];
const updatedObj = { [event.target.value]: ref.current[idx][oldKey] };
ref.current[idx] = updatedObj;
onChange(ref.current);
const oldKey = Object.keys(currentData[idx])[0];
const updatedObj = { [event.target.value]: currentData[idx][oldKey] };
const newData = [...currentData];
newData[idx] = updatedObj;
setCurrentData(newData);
onChange(newData);
};
const handleChangeValue = (newValue, idx) => {
const key = Object.keys(ref.current[idx])[0];
ref.current[idx][key] = newValue;
onChange(ref.current);
const key = Object.keys(currentData[idx])[0];
const newData = [...currentData];
newData[idx] = { ...newData[idx], [key]: newValue };
setCurrentData(newData);
onChange(newData);
};
return (
<>
<div className={classNames("flex h-full flex-col gap-3")}>
{ref.current?.map((obj, index) => {
{currentData?.map((obj, index) => {
return Object.keys(obj).map((key, idx) => {
return (
<div key={idx} className="flex w-full gap-2">
@ -66,12 +77,13 @@ const IOKeyPairInput = ({
disabled={!isInputField}
/>
{isList && isInputField && index === ref.current.length - 1 ? (
{isList && isInputField && index === currentData.length - 1 ? (
<button
type="button"
onClick={() => {
let newInputList = _.cloneDeep(ref.current);
let newInputList = _.cloneDeep(currentData);
newInputList.push({ "": "" });
setCurrentData(newInputList);
onChange(newInputList);
}}
>
@ -84,8 +96,9 @@ const IOKeyPairInput = ({
<button
type="button"
onClick={() => {
let newInputList = _.cloneDeep(ref.current);
let newInputList = _.cloneDeep(currentData);
newInputList.splice(index, 1);
setCurrentData(newInputList);
onChange(newInputList);
}}
>

View file

@ -69,11 +69,11 @@ export default function AddMcpServerModal({
setStdioName("");
setStdioCommand("");
setStdioArgs([""]);
setStdioEnv([{ "": "" }]);
setStdioEnv([]);
setSseName("");
setSseUrl("");
setSseEnv([{ "": "" }]);
setSseHeaders([{ "": "" }]);
setSseEnv([]);
setSseHeaders([]);
};
// STDIO state
@ -82,17 +82,13 @@ export default function AddMcpServerModal({
const [stdioArgs, setStdioArgs] = useState<string[]>(
initialData?.args || [""],
);
const [stdioEnv, setStdioEnv] = useState<any>(
initialData?.env || [{ "": "" }],
);
const [stdioEnv, setStdioEnv] = useState<any>(initialData?.env || []);
// SSE state
const [sseName, setSseName] = useState(initialData?.name || "");
const [sseUrl, setSseUrl] = useState(initialData?.url || "");
const [sseEnv, setSseEnv] = useState<any>(initialData?.env || [{ "": "" }]);
const [sseHeaders, setSseHeaders] = useState<any>(
initialData?.headers || [{ "": "" }],
);
const [sseEnv, setSseEnv] = useState<any>(initialData?.env || []);
const [sseHeaders, setSseHeaders] = useState<any>(initialData?.headers || []);
useEffect(() => {
if (open) {
@ -102,11 +98,11 @@ export default function AddMcpServerModal({
setStdioName(initialData?.name || "");
setStdioCommand(initialData?.command || "");
setStdioArgs(initialData?.args || [""]);
setStdioEnv(initialData?.env || [{ "": "" }]);
setStdioEnv(initialData?.env || []);
setSseName(initialData?.name || "");
setSseUrl(initialData?.url || "");
setSseEnv(initialData?.env || [{ "": "" }]);
setSseHeaders(initialData?.headers || [{ "": "" }]);
setSseEnv(initialData?.env || []);
setSseHeaders(initialData?.headers || []);
}
}, [open]);
@ -153,7 +149,7 @@ export default function AddMcpServerModal({
setStdioName("");
setStdioCommand("");
setStdioArgs([""]);
setStdioEnv([{ "": "" }]);
setStdioEnv([]);
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add MCP server.");
@ -186,8 +182,8 @@ export default function AddMcpServerModal({
setOpen(false);
setSseName("");
setSseUrl("");
setSseEnv([{ "": "" }]);
setSseHeaders([{ "": "" }]);
setSseEnv([]);
setSseHeaders([]);
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add MCP server.");

View file

@ -13,8 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APITemplateType } from "@/types/api";
import { parseString } from "@/utils/stringManipulation";
import { parseString, sanitizeMcpName } from "@/utils/stringManipulation";
import { ColDef } from "ag-grid-community";
import { AgGridReact } from "ag-grid-react";
import { cloneDeep } from "lodash";
@ -25,6 +24,7 @@ export default function ToolsTable({
data,
setData,
isAction,
placeholder,
open,
handleOnNewValue,
}: {
@ -34,6 +34,7 @@ export default function ToolsTable({
open: boolean;
handleOnNewValue: handleOnNewValueType;
isAction: boolean;
placeholder: string;
}) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<any[] | null>(null);
@ -77,7 +78,7 @@ export default function ToolsTable({
}, [agGrid.current]);
useEffect(() => {
if (!open && selectedRows) {
if (!open) {
handleOnNewValue({
value: data.map((row) => {
const name = parseString(row.name, [
@ -94,7 +95,7 @@ export default function ToolsTable({
name !== "" && name !== display_name
? name
: isAction
? ""
? sanitizeMcpName(display_name || row.name, 46)
: display_name
).slice(0, 46);
@ -172,11 +173,7 @@ export default function ToolsTable({
"uppercase",
])
: isAction
? parseString(params.data.display_name, [
"snake_case",
"no_blank",
"uppercase",
])
? sanitizeMcpName(params.data.display_name, 46).toUpperCase()
: parseString(params.data.tags.join(", "), [
"snake_case",
"uppercase",
@ -237,8 +234,10 @@ export default function ToolsTable({
};
const handleNameChange = (e) => {
setSidebarName(e.target.value);
handleSidebarInputChange("name", e.target.value);
const rawValue = e.target.value;
const sanitizedValue = isAction ? sanitizeMcpName(rawValue, 46) : rawValue;
setSidebarName(sanitizedValue);
handleSidebarInputChange("name", sanitizedValue);
};
const handleSearchChange = (e) => setSearchQuery(e.target.value);

View file

@ -1,10 +1,9 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { SidebarProvider } from "@/components/ui/sidebar";
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
import { APITemplateType } from "@/types/api";
import { AgGridReact } from "ag-grid-react";
import { cloneDeep } from "lodash";
import { ForwardedRef, forwardRef, useState } from "react";
import { ForwardedRef, forwardRef, useEffect, useState } from "react";
import BaseModal from "../baseModal";
import ToolsTable from "./components/toolsTable";
@ -18,6 +17,7 @@ interface ToolsModalProps {
description: string;
status: boolean;
}[];
placeholder: string;
handleOnNewValue: handleOnNewValueType;
title: string;
icon?: string;
@ -29,13 +29,13 @@ const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
{
description,
rows,
placeholder,
handleOnNewValue,
title,
icon,
open,
isAction = false,
setOpen,
...props
}: ToolsModalProps,
ref: ForwardedRef<AgGridReact>,
) => {
@ -47,6 +47,14 @@ const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
const [data, setData] = useState<any[]>(cloneDeep(rows));
useEffect(() => {
if (placeholder === "Loading actions...") {
handleOnNewValue({
value: [],
});
}
}, [placeholder]);
return (
<BaseModal
open={open}
@ -70,6 +78,7 @@ const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
<ToolsTable
rows={rows}
isAction={isAction}
placeholder={placeholder}
data={data}
setData={setData}
open={open}

View file

@ -112,7 +112,9 @@ export default function MCPServersPage() {
>
{server.toolsCount === null
? server.error
? "Error"
? server.error.startsWith("Timeout")
? "Timeout"
: "Error"
: "Loading..."
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`}
</span>

View file

@ -53,6 +53,7 @@ export type APIClassType = {
field_order?: string[];
tool_mode?: boolean;
type?: string;
last_updated?: string;
[key: string]:
| Array<string>
| string
@ -329,7 +330,8 @@ export type FieldParserType =
| "no_blank"
| "valid_csv"
| "space_case"
| "commands";
| "commands"
| "sanitize_mcp_name";
export type TableOptionsTypeAPI = {
block_add?: boolean;

View file

@ -72,6 +72,52 @@ function validCommands(str: string): string {
.join(", ");
}
function sanitizeMcpName(str: string, maxLength: number = 46): string {
if (!str || !str.trim()) {
return "";
}
let name = str;
// Remove emojis using standard regex patterns (without unicode flags)
// This covers most common emoji ranges using surrogate pairs
name = name.replace(/[\uD83C-\uDBFF][\uDC00-\uDFFF]/g, ""); // Most emojis
name = name.replace(/[\u2600-\u27BF]/g, ""); // Misc symbols and dingbats
name = name.replace(/[\uFE00-\uFE0F]/g, ""); // Variation selectors
name = name.replace(/\u200D/g, ""); // Zero width joiner
// Normalize unicode characters to remove diacritics
name = name.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
// Replace spaces and special characters with underscores
name = name.replace(/[^\w\s-]/g, ""); // Keep only word chars, spaces, and hyphens
name = name.replace(/[-\s]+/g, "_"); // Replace spaces and hyphens with underscores
name = name.replace(/_+/g, "_"); // Collapse multiple underscores
// Remove leading/trailing underscores
name = name.replace(/^_+|_+$/g, "");
// Ensure it starts with a letter or underscore (not a number)
if (name && /^\d/.test(name)) {
name = `_${name}`;
}
// Convert to lowercase
name = name.toLowerCase();
// Truncate to max length
if (name.length > maxLength) {
name = name.substring(0, maxLength).replace(/_+$/, "");
}
// If empty after sanitization, provide a default
if (!name) {
name = "unnamed";
}
return name;
}
export function parseString(
str: string,
parsers: FieldParserType[] | FieldParserType,
@ -127,6 +173,9 @@ export function parseString(
case "commands":
result = validCommands(result);
break;
case "sanitize_mcp_name":
result = sanitizeMcpName(result);
break;
}
} catch (error) {
throw new Error(`Error in parser ${parser}`);
@ -176,3 +225,5 @@ export const convertStringToHTML = (htmlString: string): JSX.Element => {
export const sanitizeHTML = (htmlString: string): string => {
return DOMPurify.sanitize(htmlString);
};
export { sanitizeMcpName };

11
uv.lock generated
View file

@ -5088,7 +5088,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 = ">=1.9.4" },
{ name = "mcp", specifier = ">=1.10.1" },
{ name = "mem0ai", specifier = "==0.1.34" },
{ name = "metal-sdk", specifier = "==2.5.1" },
{ name = "metaphor-python", specifier = "==0.1.23" },
@ -5366,7 +5366,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.9.4" },
{ name = "mcp", specifier = "~=1.10.1" },
{ 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" },
@ -6124,12 +6124,13 @@ wheels = [
[[package]]
name = "mcp"
version = "1.9.4"
version = "1.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
@ -6137,9 +6138,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/f2/dc2450e566eeccf92d89a00c3e813234ad58e2ba1e31d11467a09ac4f3b9/mcp-1.9.4.tar.gz", hash = "sha256:cfb0bcd1a9535b42edaef89947b9e18a8feb49362e1cc059d6e7fc636f2cb09f", size = 333294, upload-time = "2025-06-12T08:20:30.158Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/68/63045305f29ff680a9cd5be360c755270109e6b76f696ea6824547ddbc30/mcp-1.10.1.tar.gz", hash = "sha256:aaa0957d8307feeff180da2d9d359f2b801f35c0c67f1882136239055ef034c2", size = 392969, upload-time = "2025-06-27T12:03:08.982Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/fc/80e655c955137393c443842ffcc4feccab5b12fa7cb8de9ced90f90e6998/mcp-1.9.4-py3-none-any.whl", hash = "sha256:7fcf36b62936adb8e63f89346bccca1268eeca9bf6dfb562ee10b1dfbda9dac0", size = 130232, upload-time = "2025-06-12T08:20:28.551Z" },
{ url = "https://files.pythonhosted.org/packages/d7/3f/435a5b3d10ae242a9d6c2b33175551173c3c61fe637dc893be05c4ed0aaf/mcp-1.10.1-py3-none-any.whl", hash = "sha256:4d08301aefe906dce0fa482289db55ce1db831e3e67212e65b5e23ad8454b3c5", size = 150878, upload-time = "2025-06-27T12:03:07.328Z" },
]
[[package]]