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:
parent
1930fe0356
commit
6460e23bfb
21 changed files with 798 additions and 316 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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<
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
11
uv.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue