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", "arize-phoenix-otel>=0.6.1",
"openinference-instrumentation-langchain>=0.1.29", "openinference-instrumentation-langchain>=0.1.29",
"crewai==0.102.0", "crewai==0.102.0",
"mcp>=1.9.4", "mcp>=1.10.1",
"uv>=0.5.7", "uv>=0.5.7",
"scipy>=1.14.1", "scipy>=1.14.1",
"ag2>=0.1.0", "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.endpoints import simple_run_flow
from langflow.api.v1.schemas import SimplifiedAPIRequest from langflow.api.v1.schemas import SimplifiedAPIRequest
from langflow.base.mcp.constants import MAX_MCP_TOOL_NAME_LENGTH 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.helpers.flow import json_schema_from_flow
from langflow.schema.message import Message from langflow.schema.message import Message
from langflow.services.database.models.flow.model import Flow from langflow.services.database.models.flow.model import Flow
@ -186,7 +186,7 @@ async def handle_list_tools():
if flow.user_id is None: if flow.user_id is None:
continue continue
base_name = "_".join(flow.name.lower().split()) base_name = sanitize_mcp_name(flow.name)
name = base_name[:MAX_MCP_TOOL_NAME_LENGTH] name = base_name[:MAX_MCP_TOOL_NAME_LENGTH]
if name in existing_names: if name in existing_names:
i = 1 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.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.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.helpers.flow import json_schema_from_flow
from langflow.schema.message import Message from langflow.schema.message import Message
from langflow.services.database.models import Flow, Folder from langflow.services.database.models import Flow, Folder
@ -96,10 +96,10 @@ async def list_project_tools(
continue continue
# Format the flow name according to MCP conventions (snake_case) # 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 # 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 ( description = flow.action_description or (
flow.description if flow.description else f"Tool generated from flow: {flow_name}" 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 # Create the MCP configuration
mcp_config = { mcp_config = {
"mcpServers": { "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, "command": command,
"args": args, "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) 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 # 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) # Project server name pattern (must match the logic in install function)
name = project.name name = project.name
name = NEW_FOLDER_NAME if name == DEFAULT_FOLDER_NAME else 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( logger.debug(
"Checking for installed MCP servers for project: %s (server name: %s)", project.name, project_server_name "Checking for installed MCP servers for project: %s (server name: %s)", project.name, project_server_name
@ -670,7 +670,9 @@ class ProjectMCPServer:
continue continue
# Use action_name if available, otherwise construct from flow name # 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) name = get_unique_name(base_name, MAX_MCP_TOOL_NAME_LENGTH, existing_names)
# Use action_description if available, otherwise use defaults # Use action_description if available, otherwise use defaults

View file

@ -128,10 +128,14 @@ async def get_servers(
# Configuration validation errors, invalid URLs, etc. # Configuration validation errors, invalid URLs, etc.
logger.error(f"Configuration error for server {server_name}: {e}") logger.error(f"Configuration error for server {server_name}: {e}")
server_info["error"] = f"Configuration error: {e}" server_info["error"] = f"Configuration error: {e}"
except (ConnectionError, TimeoutError) as e: except ConnectionError as e:
# Network connection and timeout issues # Network connection and timeout issues
logger.error(f"Connection error for server {server_name}: {e}") logger.error(f"Connection error for server {server_name}: {e}")
server_info["error"] = f"Connection failed: {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: except OSError as e:
# System-level errors (process execution, file access) # System-level errors (process execution, file access)
logger.error(f"System error for server {server_name}: {e}") logger.error(f"System error for server {server_name}: {e}")
@ -149,6 +153,12 @@ async def get_servers(
if hasattr(e, "exceptions") and e.exceptions: if hasattr(e, "exceptions") and e.exceptions:
# Extract the first underlying exception for a more meaningful error message # Extract the first underlying exception for a more meaningful error message
underlying_error = e.exceptions[0] underlying_error = e.exceptions[0]
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}") logger.exception(f"Error checking server {server_name}: {underlying_error}")
server_info["error"] = f"Error loading server: {underlying_error}" server_info["error"] = f"Error loading server: {underlying_error}"
else: else:
@ -156,17 +166,8 @@ async def get_servers(
server_info["error"] = f"Error loading server: {e}" server_info["error"] = f"Error loading server: {e}"
return server_info 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 # 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) return await asyncio.gather(*tasks, return_exceptions=True)

View file

@ -1,7 +1,9 @@
import asyncio import asyncio
import os import os
import platform import platform
import re
import shutil import shutil
import unicodedata
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from urllib.parse import urlparse 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) 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]: def create_tool_coroutine(tool_name: str, arg_schema: type[BaseModel], client) -> Callable[..., Awaitable]:
async def tool_coroutine(*args, **kwargs): async def tool_coroutine(*args, **kwargs):
# Get field names from the model (preserving order) # 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() flows = (await session.exec(stmt)).all()
for flow in flows: 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: if this_flow_name == flow_name:
return flow return flow
return None return None
@ -295,16 +372,179 @@ async def _validate_connection_params(mode: str, command: str | None = None, url
raise ValueError(msg) raise ValueError(msg)
class MCPStdioClient: class MCPSessionManager:
"""Manages persistent MCP sessions with proper context manager lifecycle."""
def __init__(self): 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.session: ClientSession | None = None
self._connection_params = None self._connection_params = None
self._connected = False 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]: 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).""" """Connect to MCP server using stdio transport (SDK style)."""
from mcp import StdioServerParameters from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client
command = command_str.split(" ") command = command_str.split(" ")
env_data: dict[str, str] = {"DEBUG": "true", "PATH": os.environ["PATH"], **(env or {})} 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 # Store connection parameters for later use in run_tool
self._connection_params = server_params self._connection_params = server_params
try: # If no session context is set, create a default one
async with stdio_client(server_params) as (read, write), ClientSession(read, write) as session: if not self._session_context:
await session.initialize() # 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() response = await session.list_tools()
self._connected = True self._connected = True
return response.tools 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 []
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).""" """Connect to MCP server using stdio transport (SDK style)."""
try:
return await asyncio.wait_for( return await asyncio.wait_for(
self._connect_to_server(command_str, env), timeout=get_settings_service().settings.mcp_server_timeout 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}") def set_session_context(self, context_id: str):
self._connection_params = None """Set the session context (e.g., flow_id + user_id + session_id)."""
self._connected = False self._session_context = context_id
msg = f"Failed to connect to MCP stdio server: {e}"
raise ValueError(msg) from e 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:
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): async def disconnect(self):
"""Properly close the connection and clean up resources.""" """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.session = None
self._connection_params = None self._connection_params = None
self._connected = False self._connected = False
self._session_context = None
async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: 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: Args:
tool_name: Name of the tool to run 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." msg = "Session not initialized or disconnected. Call connect_to_server first."
raise ValueError(msg) raise ValueError(msg)
try: # If no session context is set, create a default one
from mcp.client.stdio import stdio_client if not self._session_context:
# Generate a fallback context based on connection parameters
import uuid
async with stdio_client(self._connection_params) as (read, write), ClientSession(read, write) as session: param_hash = uuid.uuid4().hex[:8]
await session.initialize() 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) return await session.call_tool(tool_name, arguments=arguments)
except (ConnectionError, TimeoutError, OSError, ValueError) as e: except (ConnectionError, TimeoutError, OSError, ValueError) as e:
msg = f"Failed to run tool '{tool_name}': {e}" msg = f"Failed to run tool '{tool_name}': {e}"
logger.error(msg) 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 self._connected = False
raise ValueError(msg) from e raise ValueError(msg) from e
@ -397,10 +709,28 @@ class MCPStdioClient:
class MCPSseClient: class MCPSseClient:
def __init__(self): def __init__(self, component_cache=None):
self.session: ClientSession | None = None self.session: ClientSession | None = None
self._connection_params = None self._connection_params = None
self._connected = False 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]: async def validate_url(self, url: str | None) -> tuple[bool, str]:
"""Validate the SSE URL before attempting connection.""" """Validate the SSE URL before attempting connection."""
@ -447,8 +777,6 @@ class MCPSseClient:
sse_read_timeout_seconds: int = 30, sse_read_timeout_seconds: int = 30,
) -> list[StructuredTool]: ) -> list[StructuredTool]:
"""Connect to MCP server using SSE transport (SDK style).""" """Connect to MCP server using SSE transport (SDK style)."""
from mcp.client.sse import sse_client
if headers is None: if headers is None:
headers = {} headers = {}
if url is None: if url is None:
@ -469,42 +797,54 @@ class MCPSseClient:
"sse_read_timeout_seconds": sse_read_timeout_seconds, "sse_read_timeout_seconds": sse_read_timeout_seconds,
} }
try: # If no session context is set, create a default one
async with ( if not self._session_context:
sse_client(url, headers, timeout_seconds, sse_read_timeout_seconds) as (read, write), # Generate a fallback context based on connection parameters
ClientSession(read, write) as session, import uuid
):
await session.initialize() 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() response = await session.list_tools()
self._connected = True self._connected = True
return response.tools 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]: 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).""" """Connect to MCP server using SSE transport (SDK style)."""
try:
return await asyncio.wait_for( 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), 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}") def set_session_context(self, context_id: str):
self._connection_params = None """Set the session context (e.g., flow_id + user_id + session_id)."""
self._connected = False self._session_context = context_id
return []
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): async def disconnect(self):
"""Properly close the connection and clean up resources.""" """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.session = None
self._connection_params = None self._connection_params = None
self._connected = False self._connected = False
self._session_context = None
async def run_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: 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: Args:
tool_name: Name of the tool to run 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." msg = "Session not initialized or disconnected. Call connect_to_server first."
raise ValueError(msg) raise ValueError(msg)
try: # If no session context is set, create a default one
from mcp.client.sse import sse_client if not self._session_context:
# Generate a fallback context based on connection parameters
import uuid
params = self._connection_params param_hash = uuid.uuid4().hex[:8]
async with ( self._session_context = f"default_sse_{param_hash}"
sse_client(
params["url"], params["headers"], params["timeout_seconds"], params["sse_read_timeout_seconds"] try:
) as (read, write), # Get or create persistent session
ClientSession(read, write) as session, session = await self._get_or_create_session()
):
await session.initialize()
return await session.call_tool(tool_name, arguments=arguments) return await session.call_tool(tool_name, arguments=arguments)
except (ConnectionError, TimeoutError, OSError, ValueError) as e: except (ConnectionError, TimeoutError, OSError, ValueError) as e:
msg = f"Failed to run tool '{tool_name}': {e}" msg = f"Failed to run tool '{tool_name}': {e}"
logger.error(msg) 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 self._connected = False
raise ValueError(msg) from e raise ValueError(msg) from e
@ -562,7 +906,6 @@ async def update_tools(
if mcp_sse_client is None: if mcp_sse_client is None:
mcp_sse_client = MCPSseClient() mcp_sse_client = MCPSseClient()
try:
# Fetch server config from backend # Fetch server config from backend
mode = "Stdio" if "command" in server_config else "SSE" if "url" in server_config else "" mode = "Stdio" if "command" in server_config else "SSE" if "url" in server_config else ""
command = server_config.get("command", "") command = server_config.get("command", "")
@ -574,11 +917,10 @@ async def update_tools(
await _validate_connection_params(mode, command, url) await _validate_connection_params(mode, command, url)
except ValueError as e: except ValueError as e:
logger.error(f"Invalid MCP server configuration for '{server_name}': {e}") logger.error(f"Invalid MCP server configuration for '{server_name}': {e}")
return "", [], {} raise
# Determine connection type and parameters # Determine connection type and parameters
client: MCPStdioClient | MCPSseClient | None = None client: MCPStdioClient | MCPSseClient | None = None
try:
if mode == "Stdio": if mode == "Stdio":
# Stdio connection # Stdio connection
args = server_config.get("args", []) args = server_config.get("args", [])
@ -593,12 +935,6 @@ async def update_tools(
else: else:
logger.error(f"Invalid MCP server mode for '{server_name}': {mode}") logger.error(f"Invalid MCP server mode for '{server_name}': {mode}")
return "", [], {} return "", [], {}
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)
raise ValueError(msg) from e
if not tools or not client or not client._connected: if not tools or not client or not client._connected:
logger.warning(f"No tools available from MCP server '{server_name}' or connection failed") logger.warning(f"No tools available from MCP server '{server_name}' or connection failed")
@ -632,8 +968,4 @@ async def update_tools(
raise ValueError(msg) from e raise ValueError(msg) from e
logger.info(f"Successfully loaded {len(tool_list)} tools from MCP server '{server_name}'") 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 return mode, tool_list, tool_cache

View file

@ -1,6 +1,10 @@
import asyncio
import re import re
import uuid
from typing import Any from typing import Any
from langchain_core.tools import StructuredTool
from langflow.api.v2.mcp import get_server from langflow.api.v2.mcp import get_server
from langflow.base.mcp.util import ( from langflow.base.mcp.util import (
MCPSseClient, MCPSseClient,
@ -10,13 +14,12 @@ from langflow.base.mcp.util import (
) )
from langflow.custom.custom_component.component_with_cache import ComponentWithCache from langflow.custom.custom_component.component_with_cache import ComponentWithCache
from langflow.inputs.inputs import InputTypes 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.io.schema import flatten_schema, schema_to_langflow_inputs
from langflow.logging import logger from langflow.logging import logger
from langflow.schema.dataframe import DataFrame from langflow.schema.dataframe import DataFrame
from langflow.schema.message import Message from langflow.schema.message import Message
from langflow.services.auth.utils import create_user_longterm_token from langflow.services.auth.utils import create_user_longterm_token
from langflow.services.cache.utils import CacheMiss
# Import get_server from the backend API # Import get_server from the backend API
from langflow.services.database.models.user.crud import get_user_by_id 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): class MCPToolsComponent(ComponentWithCache):
schema_inputs: list = [] schema_inputs: list = []
stdio_client: MCPStdioClient = MCPStdioClient() tools: list[StructuredTool] = []
sse_client: MCPSseClient = MCPSseClient() _not_load_actions: bool = False
tools: list = []
_tool_cache: dict = {} _tool_cache: dict = {}
_last_selected_server: str | None = None # Cache for the last selected server _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] = [ default_keys: list[str] = [
"code", "code",
"_type", "_type",
@ -154,8 +169,8 @@ class MCPToolsComponent(ComponentWithCache):
return [], {"name": server_name, "config": server_config_from_value} return [], {"name": server_name, "config": server_config_from_value}
# Use shared cache if available # Use shared cache if available
cached = self._shared_component_cache.get(server_name) cached = self._shared_component_cache["servers"].get(server_name)
if not isinstance(cached, CacheMiss): if cached is not None:
self.tools = cached["tools"] self.tools = cached["tools"]
self.tool_names = cached["tool_names"] self.tool_names = cached["tool_names"]
self._tool_cache = cached["tool_cache"] self._tool_cache = cached["tool_cache"]
@ -195,16 +210,18 @@ class MCPToolsComponent(ComponentWithCache):
self._tool_cache = tool_cache self._tool_cache = tool_cache
self.tools = tool_list self.tools = tool_list
# Cache the result using shared cache # Cache the result using shared cache
self._shared_component_cache.set(
server_name, self._shared_component_cache["servers"][server_name] = {
{
"tools": tool_list, "tools": tool_list,
"tool_names": self.tool_names, "tool_names": self.tool_names,
"tool_cache": tool_cache, "tool_cache": tool_cache,
"config": server_config, "config": server_config,
}, }
)
return tool_list, {"name": server_name, "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: except Exception as e:
msg = f"Error updating tool list: {e!s}" msg = f"Error updating tool list: {e!s}"
logger.exception(msg) logger.exception(msg)
@ -219,22 +236,35 @@ class MCPToolsComponent(ComponentWithCache):
try: try:
self.tools, build_config["mcp_server"]["value"] = await self.update_tool_list() 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"]["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: except ValueError:
if not build_config["tools_metadata"]["show"]:
build_config["tool"]["show"] = True
build_config["tool"]["options"] = [] build_config["tool"]["options"] = []
build_config["tool"]["value"] = "" build_config["tool"]["value"] = ""
build_config["tool"]["placeholder"] = "Error on MCP Server" build_config["tool"]["placeholder"] = "Error on MCP Server"
return build_config else:
build_config["tool"]["placeholder"] = "" build_config["tool"]["show"] = False
if field_value == "": if field_value == "":
return build_config return build_config
tool_obj = None tool_obj = None
for tool in self.tools: for tool in self.tools:
if tool.name == self.tool: if tool.name == field_value:
tool_obj = tool tool_obj = tool
break break
if tool_obj is None: 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) logger.warning(msg)
return build_config return build_config
await self._update_tool_config(build_config, field_value) await self._update_tool_config(build_config, field_value)
@ -254,71 +284,63 @@ class MCPToolsComponent(ComponentWithCache):
return build_config return build_config
current_server_name = field_value.get("name") if isinstance(field_value, dict) else field_value 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 # 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 return build_config
# Determine if "Tool Mode" is active by checking if the tool dropdown is hidden. # 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 is_in_tool_mode = build_config["tools_metadata"]["show"]
# hidden by default but we are not yet in "Tool Mode". self._shared_component_cache.set("last_selected_server", current_server_name)
is_in_tool_mode = build_config["tools_metadata"]["value"]
self._last_selected_server = current_server_name # Check if tools are already cached for this server before clearing
self.tools = [] # Clear previous tools 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 self.remove_non_default_keys(build_config) # Clear previous tool inputs
# Only show the tool dropdown if not in tool_mode # Only show the tool dropdown if not in tool_mode
if not is_in_tool_mode: if not is_in_tool_mode:
build_config["tool"]["show"] = True build_config["tool"]["show"] = True
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"]["placeholder"] = "Loading tools..."
build_config["tool"]["options"] = [] build_config["tool"]["options"] = []
build_config["tool"]["value"] = "" build_config["tool"]["value"] = uuid.uuid4()
else: else:
# Keep the tool dropdown hidden if in tool_mode # Keep the tool dropdown hidden if in tool_mode
self._not_load_actions = True
build_config["tool"]["show"] = False 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": 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"]["placeholder"] = ""
build_config["tool"]["show"] = not field_value build_config["tool"]["show"] = not field_value
for key, value in list(build_config.items()): self.remove_non_default_keys(build_config)
if key not in self.default_keys and isinstance(value, dict) and "show" in value: self.tool = build_config["tool"]["value"]
build_config[key]["show"] = not field_value if field_value:
if not field_value: self._not_load_actions = True
build_config["tool"]["options"] = [tool.name for tool in self.tools] else:
await self._update_tool_config(build_config, build_config["tool"]["value"]) 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: except Exception as e:
msg = f"Error in update_build_config: {e!s}" msg = f"Error in update_build_config: {e!s}"
@ -434,6 +456,12 @@ class MCPToolsComponent(ComponentWithCache):
try: try:
self.tools, _ = await self.update_tool_list() self.tools, _ = await self.update_tool_list()
if self.tool != "": 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] exec_tool = self._tool_cache[self.tool]
tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool] tool_args = self.get_inputs_for_all_tools(self.tools)[self.tool]
kwargs = {} kwargs = {}
@ -460,8 +488,25 @@ class MCPToolsComponent(ComponentWithCache):
logger.exception(msg) logger.exception(msg)
raise ValueError(msg) from e 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): async def _get_tools(self):
"""Get cached tools or update if necessary.""" """Get cached tools or update if necessary."""
mcp_server = getattr(self, "mcp_server", None) mcp_server = getattr(self, "mcp_server", None)
if not self._not_load_actions:
tools, _ = await self.update_tool_list(mcp_server) tools, _ = await self.update_tool_list(mcp_server)
return tools return tools
return []

View file

@ -1294,7 +1294,20 @@ class Component(CustomComponent):
} }
async def _build_tools_metadata_input(self): async def _build_tools_metadata_input(self):
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() 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 # Always use the latest tool data
tool_data = [self._build_tool_data(tool) for tool in tools] tool_data = [self._build_tool_data(tool) for tool in tools]
# print(tool_data) # 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) item["status"] = any(enabled_name in [item["name"], *item["tags"]] for enabled_name in enabled)
self.tools_metadata = tool_data 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( return ToolsInput(
name=TOOLS_METADATA_INPUT_NAME, name=TOOLS_METADATA_INPUT_NAME,
placeholder=placeholder,
display_name="Actions", display_name="Actions",
info=TOOLS_METADATA_INFO, info=TOOLS_METADATA_INFO,
value=tool_data, value=tool_data,

View file

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

View file

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

View file

@ -59,7 +59,7 @@ export const mutateTemplate = async (
newTemplate.outputs ?? [], newTemplate.outputs ?? [],
); );
newNode.tool_mode = toolMode ?? node.tool_mode; newNode.tool_mode = toolMode ?? node.tool_mode;
} newNode.last_updated = newTemplate.last_updated;
try { try {
setNodeClass(newNode); setNodeClass(newNode);
} catch (e) { } catch (e) {
@ -69,6 +69,7 @@ export const mutateTemplate = async (
throw e; throw e;
} }
} }
}
callback?.(); callback?.();
} catch (e) { } catch (e) {
const error = e as ResponseErrorDetailAPI; const error = e as ResponseErrorDetailAPI;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -72,6 +72,52 @@ function validCommands(str: string): string {
.join(", "); .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( export function parseString(
str: string, str: string,
parsers: FieldParserType[] | FieldParserType, parsers: FieldParserType[] | FieldParserType,
@ -127,6 +173,9 @@ export function parseString(
case "commands": case "commands":
result = validCommands(result); result = validCommands(result);
break; break;
case "sanitize_mcp_name":
result = sanitizeMcpName(result);
break;
} }
} catch (error) { } catch (error) {
throw new Error(`Error in parser ${parser}`); throw new Error(`Error in parser ${parser}`);
@ -176,3 +225,5 @@ export const convertStringToHTML = (htmlString: string): JSX.Element => {
export const sanitizeHTML = (htmlString: string): string => { export const sanitizeHTML = (htmlString: string): string => {
return DOMPurify.sanitize(htmlString); 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 = "llama-cpp-python", marker = "extra == 'local'", specifier = "~=0.2.0" },
{ name = "markdown", specifier = "==3.7" }, { name = "markdown", specifier = "==3.7" },
{ name = "markupsafe", specifier = "==3.0.2" }, { 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 = "mem0ai", specifier = "==0.1.34" },
{ name = "metal-sdk", specifier = "==2.5.1" }, { name = "metal-sdk", specifier = "==2.5.1" },
{ name = "metaphor-python", specifier = "==0.1.23" }, { 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 == 'all'", specifier = ">=0.2.0" },
{ name = "llama-cpp-python", marker = "extra == 'local'", 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 = "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 = "multiprocess", specifier = ">=0.70.14,<1.0.0" },
{ name = "nanoid", specifier = ">=2.0.0,<3.0.0" }, { name = "nanoid", specifier = ">=2.0.0,<3.0.0" },
{ name = "nest-asyncio", specifier = ">=1.6.0,<2.0.0" }, { name = "nest-asyncio", specifier = ">=1.6.0,<2.0.0" },
@ -6124,12 +6124,13 @@ wheels = [
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.9.4" version = "1.10.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "httpx" }, { name = "httpx" },
{ name = "httpx-sse" }, { name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" }, { name = "python-multipart" },
@ -6137,9 +6138,9 @@ dependencies = [
{ name = "starlette" }, { name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" }, { 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 = [ 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]] [[package]]