diff --git a/src/backend/base/langflow/api/v1/mcp_projects.py b/src/backend/base/langflow/api/v1/mcp_projects.py index dcf8c617f..67f037f04 100644 --- a/src/backend/base/langflow/api/v1/mcp_projects.py +++ b/src/backend/base/langflow/api/v1/mcp_projects.py @@ -2,8 +2,13 @@ import asyncio import base64 import json import logging +import os +import platform +from asyncio.subprocess import create_subprocess_exec from contextvars import ContextVar from datetime import datetime, timezone +from ipaddress import ip_address +from pathlib import Path from typing import Annotated from urllib.parse import quote, unquote, urlparse from uuid import UUID, uuid4 @@ -24,12 +29,12 @@ from langflow.api.v1.mcp import ( handle_mcp_errors, with_db_session, ) -from langflow.api.v1.schemas import InputValueRequest, MCPSettings +from langflow.api.v1.schemas import InputValueRequest, MCPInstallRequest, MCPSettings from langflow.base.mcp.util import get_flow_snake_case from langflow.helpers.flow import json_schema_from_flow -from langflow.services.auth.utils import get_current_active_user, get_current_user +from langflow.services.auth.utils import get_current_active_user from langflow.services.database.models import Flow, Folder, User -from langflow.services.deps import get_db_service, get_settings_service, get_storage_service +from langflow.services.deps import get_settings_service, get_storage_service, session_scope from langflow.services.storage.utils import build_content_type_from_extension logger = logging.getLogger(__name__) @@ -51,18 +56,17 @@ def get_project_sse(project_id: UUID) -> SseServerTransport: return project_sse_transports[project_id_str] -@router.get("/{project_id}", response_model=list[MCPSettings], dependencies=[Depends(get_current_user)]) +@router.get("/{project_id}") async def list_project_tools( project_id: UUID, current_user: Annotated[User, Depends(get_current_active_user)], *, mcp_enabled: bool = True, -): +) -> list[MCPSettings]: """List all tools in a project that are enabled for MCP.""" tools: list[MCPSettings] = [] try: - db_service = get_db_service() - async with db_service.with_session() as session: + async with session_scope() as session: # Fetch the project first to verify it exists and belongs to the current user project = ( await session.exec( @@ -120,6 +124,378 @@ async def list_project_tools( return tools +@router.head("/{project_id}/sse", response_class=HTMLResponse, include_in_schema=False) +async def im_alive(): + return Response() + + +@router.get("/{project_id}/sse", response_class=HTMLResponse) +async def handle_project_sse( + project_id: UUID, + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Handle SSE connections for a specific project.""" + # Verify project exists and user has access + async with session_scope() as session: + project = ( + await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == current_user.id)) + ).first() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Get project-specific SSE transport and MCP server + sse = get_project_sse(project_id) + project_server = get_project_mcp_server(project_id) + logger.debug("Project MCP server name: %s", project_server.server.name) + + # Set context variables + user_token = current_user_ctx.set(current_user) + project_token = current_project_ctx.set(project_id) + + try: + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: + try: + logger.debug("Starting SSE connection for project %s", project_id) + + notification_options = NotificationOptions( + prompts_changed=True, resources_changed=True, tools_changed=True + ) + init_options = project_server.server.create_initialization_options(notification_options) + + try: + await project_server.server.run(streams[0], streams[1], init_options) + except Exception: + logger.exception("Error in project MCP") + except BrokenResourceError: + logger.info("Client disconnected from project SSE connection") + except asyncio.CancelledError: + logger.info("Project SSE connection was cancelled") + raise + except Exception: + logger.exception("Error in project MCP") + raise + finally: + current_user_ctx.reset(user_token) + current_project_ctx.reset(project_token) + + return Response(status_code=200) + + +@router.post("/{project_id}") +async def handle_project_messages( + project_id: UUID, request: Request, current_user: Annotated[User, Depends(get_current_active_user)] +): + """Handle POST messages for a project-specific MCP server.""" + # Verify project exists and user has access + async with session_scope() as session: + project = ( + await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == current_user.id)) + ).first() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Set context variables + user_token = current_user_ctx.set(current_user) + project_token = current_project_ctx.set(project_id) + + try: + sse = get_project_sse(project_id) + await sse.handle_post_message(request.scope, request.receive, request._send) + except BrokenResourceError as e: + logger.info("Project MCP Server disconnected for project %s", project_id) + raise HTTPException(status_code=404, detail=f"Project MCP Server disconnected, error: {e}") from e + finally: + current_user_ctx.reset(user_token) + current_project_ctx.reset(project_token) + + +@router.post("/{project_id}/") +async def handle_project_messages_with_slash( + project_id: UUID, request: Request, current_user: Annotated[User, Depends(get_current_active_user)] +): + """Handle POST messages for a project-specific MCP server with trailing slash.""" + # Call the original handler + return await handle_project_messages(project_id, request, current_user) + + +@router.patch("/{project_id}", status_code=200) +async def update_project_mcp_settings( + project_id: UUID, + settings: list[MCPSettings], + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Update the MCP settings of all flows in a project.""" + try: + async with session_scope() as session: + # Fetch the project first to verify it exists and belongs to the current user + project = ( + await session.exec( + select(Folder) + .options(selectinload(Folder.flows)) + .where(Folder.id == project_id, Folder.user_id == current_user.id) + ) + ).first() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Query flows in the project + flows = (await session.exec(select(Flow).where(Flow.folder_id == project_id))).all() + flows_to_update = {x.id: x for x in settings} + + updated_flows = [] + for flow in flows: + if flow.user_id is None or flow.user_id != current_user.id: + continue + + if flow.id in flows_to_update: + settings_to_update = flows_to_update[flow.id] + flow.mcp_enabled = settings_to_update.mcp_enabled + flow.action_name = settings_to_update.action_name + flow.action_description = settings_to_update.action_description + flow.updated_at = datetime.now(timezone.utc) + session.add(flow) + updated_flows.append(flow) + + await session.commit() + + return {"message": f"Updated MCP settings for {len(updated_flows)} flows"} + + except Exception as e: + msg = f"Error updating project MCP settings: {e!s}" + logger.exception(msg) + raise HTTPException(status_code=500, detail=str(e)) from e + + +def is_local_ip(ip_str: str) -> bool: + """Check if an IP address is a loopback address (same machine). + + Args: + ip_str: String representation of an IP address + + Returns: + bool: True if the IP is a loopback address, False otherwise + """ + # Check if it's exactly "localhost" + if ip_str == "localhost": + return True + + # Check if it's exactly "0.0.0.0" (which binds to all interfaces) + if ip_str == "0.0.0.0": # noqa: S104 + return True + + try: + # Convert string to IP address object + ip = ip_address(ip_str) + + # Check if it's a loopback address (127.0.0.0/8 for IPv4, ::1 for IPv6) + return bool(ip.is_loopback) + except ValueError: + # If the IP address is invalid, default to False + return False + + +def get_client_ip(request: Request) -> str: + """Extract the client IP address from a FastAPI request. + + Args: + request: FastAPI Request object + + Returns: + str: The client's IP address + """ + # Check for X-Forwarded-For header (common when behind proxies) + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + # The client IP is the first one in the list + return forwarded_for.split(",")[0].strip() + + # If no proxy headers, use the client's direct IP + if request.client: + return request.client.host + + # Fallback if we can't determine the IP - use a non-local IP + return "255.255.255.255" # Non-routable IP that will never be local + + +@router.post("/{project_id}/install") +async def install_mcp_config( + project_id: UUID, + body: MCPInstallRequest, + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Install MCP server configuration for Cursor or Claude.""" + # Check if the request is coming from a local IP address + client_ip = get_client_ip(request) + if not is_local_ip(client_ip): + raise HTTPException(status_code=500, detail="MCP configuration can only be installed from a local connection") + + try: + # Verify project exists and user has access + async with session_scope() as session: + project = ( + await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == current_user.id)) + ).first() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Get settings service to build the SSE URL + settings_service = get_settings_service() + host = getattr(settings_service.settings, "host", "localhost") + port = getattr(settings_service.settings, "port", 3000) + base_url = f"http://{host}:{port}".rstrip("/") + sse_url = f"{base_url}/api/v1/mcp/project/{project_id}/sse" + + # Determine command and args based on operating system + os_type = platform.system() + command = "uvx" + args = ["mcp-proxy", sse_url] + + # Check if running on WSL (will appear as Linux but with Microsoft in release info) + if os_type == "Linux" and "microsoft" in platform.uname().release.lower(): + logger.debug("WSL detected, using Windows-specific configuration") + + # If we're in WSL and the host is localhost, we might need to adjust the URL + # so Windows applications can reach the WSL service + if host in {"localhost", "127.0.0.1"}: + try: + # Try to get the WSL IP address for host.docker.internal or similar access + + # This might vary depending on WSL version and configuration + proc = await create_subprocess_exec( + "/usr/bin/hostname", + "-I", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode == 0 and stdout.strip(): + wsl_ip = stdout.decode().strip().split()[0] # Get first IP address + logger.debug("Using WSL IP for external access: %s", wsl_ip) + # Replace the localhost with the WSL IP in the URL + sse_url = sse_url.replace(f"http://{host}:{port}", f"http://{wsl_ip}:{port}") + except OSError as e: + logger.warning("Failed to get WSL IP address: %s. Using default URL.", str(e)) + + if os_type == "Windows": + command = "cmd" + args = ["/c", "uvx", "mcp-proxy", sse_url] + logger.debug("Windows detected, using cmd command") + + # Create the MCP configuration + mcp_config = { + "mcpServers": {f"lf-{project.name.lower().replace(' ', '_')[:11]}": {"command": command, "args": args}} + } + + # Determine the config file path based on the client and OS + if body.client.lower() == "cursor": + config_path = Path.home() / ".cursor" / "mcp.json" + elif body.client.lower() == "claude": + if os_type == "Darwin": # macOS + config_path = Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" + elif os_type == "Windows": + config_path = Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json" + else: + raise HTTPException(status_code=400, detail="Unsupported operating system for Claude configuration") + else: + raise HTTPException(status_code=400, detail="Unsupported client") + + # Create parent directories if they don't exist + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Read existing config if it exists + existing_config = {} + if config_path.exists(): + try: + with config_path.open("r") as f: + existing_config = json.load(f) + except json.JSONDecodeError: + # If file exists but is invalid JSON, start fresh + existing_config = {"mcpServers": {}} + + # Merge new config with existing config + if "mcpServers" not in existing_config: + existing_config["mcpServers"] = {} + existing_config["mcpServers"].update(mcp_config["mcpServers"]) + + # Write the updated config + with config_path.open("w") as f: + json.dump(existing_config, f, indent=2) + + except Exception as e: + msg = f"Error installing MCP configuration: {e!s}" + logger.exception(msg) + raise HTTPException(status_code=500, detail=str(e)) from e + else: + message = f"Successfully installed MCP configuration for {body.client}" + logger.info(message) + return {"message": message} + + +@router.get("/{project_id}/installed") +async def check_installed_mcp_servers( + project_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Check if MCP server configuration is installed for this project in Cursor or Claude.""" + try: + # Verify project exists and user has access + async with session_scope() as session: + project = ( + await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == current_user.id)) + ).first() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Project server name pattern + project_server_name = f"lf-{project.name.lower().replace(' ', '_')[:11]}" + + # Check configurations for different clients + results = [] + + # Check Cursor configuration + cursor_config_path = Path.home() / ".cursor" / "mcp.json" + if cursor_config_path.exists(): + try: + with cursor_config_path.open("r") as f: + cursor_config = json.load(f) + if "mcpServers" in cursor_config and project_server_name in cursor_config["mcpServers"]: + results.append("cursor") + except json.JSONDecodeError: + pass + + # Check Claude configuration + claude_config_path = None + if platform.system() == "Darwin": # macOS + claude_config_path = ( + Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" + ) + elif platform.system() == "Windows": + claude_config_path = Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json" + + if claude_config_path and claude_config_path.exists(): + try: + with claude_config_path.open("r") as f: + claude_config = json.load(f) + if "mcpServers" in claude_config and project_server_name in claude_config["mcpServers"]: + results.append("claude") + except json.JSONDecodeError: + pass + + except Exception as e: + msg = f"Error checking MCP configuration: {e!s}" + logger.exception(msg) + raise HTTPException(status_code=500, detail=str(e)) from e + return results + + # Project-specific MCP server instance for handling project-specific tools class ProjectMCPServer: def __init__(self, project_id: UUID): @@ -133,8 +509,7 @@ class ProjectMCPServer: """Handle listing tools for this specific project.""" tools = [] try: - db_service = get_db_service() - async with db_service.with_session() as session: + async with session_scope() as session: # Get flows with mcp_enabled flag set to True and in this project flows = ( await session.exec( @@ -173,7 +548,6 @@ class ProjectMCPServer: async def handle_list_resources(): resources = [] try: - db_service = get_db_service() storage_service = get_storage_service() settings_service = get_settings_service() @@ -183,7 +557,7 @@ class ProjectMCPServer: base_url = f"http://{host}:{port}".rstrip("/") - async with db_service.with_session() as session: + async with session_scope() as session: flows = (await session.exec(select(Flow))).all() for flow in flows: @@ -388,8 +762,7 @@ def get_project_mcp_server(project_id: UUID) -> ProjectMCPServer: async def init_mcp_servers(): """Initialize MCP servers for all projects.""" try: - db_service = get_db_service() - async with db_service.with_session() as session: + async with session_scope() as session: projects = (await session.exec(select(Folder))).all() for project in projects: @@ -404,153 +777,3 @@ async def init_mcp_servers(): except Exception as e: msg = f"Failed to initialize MCP servers: {e}" logger.exception(msg) - - -@router.head("/{project_id}/sse", response_class=HTMLResponse, include_in_schema=False) -async def im_alive(): - return Response() - - -@router.get("/{project_id}/sse", response_class=HTMLResponse, dependencies=[Depends(get_current_user)]) -async def handle_project_sse( - project_id: UUID, - request: Request, - current_user: Annotated[User, Depends(get_current_active_user)], -): - """Handle SSE connections for a specific project.""" - # Verify project exists and user has access - db_service = get_db_service() - async with db_service.with_session() as session: - project = ( - await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == current_user.id)) - ).first() - - if not project: - raise HTTPException(status_code=404, detail="Project not found") - - # Get project-specific SSE transport and MCP server - sse = get_project_sse(project_id) - project_server = get_project_mcp_server(project_id) - msg = f"Project MCP server name: {project_server.server.name}" - logger.info(msg) - - # Set context variables - user_token = current_user_ctx.set(current_user) - project_token = current_project_ctx.set(project_id) - - try: - async with sse.connect_sse(request.scope, request.receive, request._send) as streams: - try: - logger.debug("Starting SSE connection for project %s", project_id) - - notification_options = NotificationOptions( - prompts_changed=True, resources_changed=True, tools_changed=True - ) - init_options = project_server.server.create_initialization_options(notification_options) - - try: - await project_server.server.run(streams[0], streams[1], init_options) - except Exception: - logger.exception("Error in project MCP") - except BrokenResourceError: - logger.info("Client disconnected from project SSE connection") - except asyncio.CancelledError: - logger.info("Project SSE connection was cancelled") - raise - except Exception: - logger.exception("Error in project MCP") - raise - finally: - current_user_ctx.reset(user_token) - current_project_ctx.reset(project_token) - - return Response(status_code=200) - - -@router.post("/{project_id}", dependencies=[Depends(get_current_user)]) -async def handle_project_messages( - project_id: UUID, request: Request, current_user: Annotated[User, Depends(get_current_active_user)] -): - """Handle POST messages for a project-specific MCP server.""" - # Verify project exists and user has access - db_service = get_db_service() - async with db_service.with_session() as session: - project = ( - await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == current_user.id)) - ).first() - - if not project: - raise HTTPException(status_code=404, detail="Project not found") - - # Set context variables - user_token = current_user_ctx.set(current_user) - project_token = current_project_ctx.set(project_id) - - try: - sse = get_project_sse(project_id) - await sse.handle_post_message(request.scope, request.receive, request._send) - except BrokenResourceError as e: - logger.info("Project MCP Server disconnected for project %s", project_id) - raise HTTPException(status_code=404, detail=f"Project MCP Server disconnected, error: {e}") from e - finally: - current_user_ctx.reset(user_token) - current_project_ctx.reset(project_token) - - -@router.post("/{project_id}/", dependencies=[Depends(get_current_user)]) -async def handle_project_messages_with_slash( - project_id: UUID, request: Request, current_user: Annotated[User, Depends(get_current_active_user)] -): - """Handle POST messages for a project-specific MCP server with trailing slash.""" - # Call the original handler - return await handle_project_messages(project_id, request, current_user) - - -@router.patch("/{project_id}", status_code=200, dependencies=[Depends(get_current_user)]) -async def update_project_mcp_settings( - project_id: UUID, - settings: list[MCPSettings], - current_user: Annotated[User, Depends(get_current_active_user)], -): - """Update the MCP settings of all flows in a project.""" - try: - db_service = get_db_service() - async with db_service.with_session() as session: - # Fetch the project first to verify it exists and belongs to the current user - project = ( - await session.exec( - select(Folder) - .options(selectinload(Folder.flows)) - .where(Folder.id == project_id, Folder.user_id == current_user.id) - ) - ).first() - - if not project: - raise HTTPException(status_code=404, detail="Project not found") - - # Query flows in the project - flows = (await session.exec(select(Flow).where(Flow.folder_id == project_id))).all() - flows_to_update = {x.id: x for x in settings} - - updated_flows = [] - for flow in flows: - if flow.user_id is None or flow.user_id != current_user.id: - continue - - if flow.id in flows_to_update: - settings_to_update = flows_to_update[flow.id] - flow.mcp_enabled = settings_to_update.mcp_enabled - flow.action_name = settings_to_update.action_name - flow.action_description = settings_to_update.action_description - flow.updated_at = datetime.now(timezone.utc) - session.add(flow) - updated_flows.append(flow) - - await session.commit() - - return {"message": f"Updated MCP settings for {len(updated_flows)} flows"} - - except Exception as e: - msg = f"Error updating project MCP settings: {e!s}" - logger.exception(msg) - raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/src/backend/base/langflow/api/v1/schemas.py b/src/backend/base/langflow/api/v1/schemas.py index d71347c00..26823c8c7 100644 --- a/src/backend/base/langflow/api/v1/schemas.py +++ b/src/backend/base/langflow/api/v1/schemas.py @@ -406,3 +406,7 @@ class MCPSettings(BaseModel): action_description: str | None = None name: str | None = None description: str | None = None + + +class MCPInstallRequest(BaseModel): + client: str diff --git a/src/backend/tests/unit/api/v1/test_mcp_projects.py b/src/backend/tests/unit/api/v1/test_mcp_projects.py index 44cc8217d..5de5c6321 100644 --- a/src/backend/tests/unit/api/v1/test_mcp_projects.py +++ b/src/backend/tests/unit/api/v1/test_mcp_projects.py @@ -15,8 +15,7 @@ from langflow.services.auth.utils import get_password_hash from langflow.services.database.models.flow import Flow from langflow.services.database.models.folder import Folder from langflow.services.database.models.user import User -from langflow.services.database.utils import session_getter -from langflow.services.deps import get_db_service +from langflow.services.deps import session_scope from mcp.server.sse import SseServerTransport # Mark all tests in this module as asyncio @@ -100,8 +99,7 @@ def mock_current_project_ctx(mock_project): async def other_test_user(): """Fixture for creating another test user.""" user_id = uuid4() - db_manager = get_db_service() - async with db_manager.with_session() as session: + async with session_scope() as session: user = User( id=user_id, username="other_test_user", @@ -114,7 +112,7 @@ async def other_test_user(): await session.refresh(user) yield user # Clean up - async with db_manager.with_session() as session: + async with session_scope() as session: user = await session.get(User, user_id) if user: await session.delete(user) @@ -125,15 +123,14 @@ async def other_test_user(): async def other_test_project(other_test_user): """Fixture for creating a project for another test user.""" project_id = uuid4() - db_manager = get_db_service() - async with db_manager.with_session() as session: + async with session_scope() as session: project = Folder(id=project_id, name="Other Test Project", user_id=other_test_user.id) session.add(project) await session.commit() await session.refresh(project) yield project # Clean up - async with db_manager.with_session() as session: + async with session_scope() as session: project = await session.get(Folder, project_id) if project: await session.delete(project) @@ -144,9 +141,9 @@ async def test_handle_project_messages_success( client: AsyncClient, mock_project, mock_sse_transport, logged_in_headers ): """Test successful handling of project messages.""" - with patch("langflow.api.v1.mcp_projects.get_db_service") as mock_db: + with patch("langflow.api.v1.mcp_projects.session_scope") as mock_db: mock_session = AsyncMock() - mock_db.return_value.with_session.return_value.__aenter__.return_value = mock_session + mock_db.return_value.__aenter__.return_value = mock_session mock_session.exec.return_value.first.return_value = mock_project response = await client.post( @@ -160,9 +157,9 @@ async def test_handle_project_messages_success( async def test_update_project_mcp_settings_invalid_json(client: AsyncClient, mock_project, logged_in_headers): """Test updating MCP settings with invalid JSON.""" - with patch("langflow.api.v1.mcp_projects.get_db_service") as mock_db: + with patch("langflow.api.v1.mcp_projects.session_scope") as mock_db: mock_session = AsyncMock() - mock_db.return_value.with_session.return_value.__aenter__.return_value = mock_session + mock_db.return_value.__aenter__.return_value = mock_session mock_session.exec.return_value.first.return_value = mock_project response = await client.patch( @@ -187,8 +184,7 @@ async def test_flow_for_update(active_user, user_test_project): } # Create the flow in the database - db_manager = get_db_service() - async with db_manager.with_session() as session: + async with session_scope() as session: flow = Flow(**flow_data) session.add(flow) await session.commit() @@ -197,7 +193,8 @@ async def test_flow_for_update(active_user, user_test_project): yield flow # Clean up - async with db_manager.with_session() as session: + async with session_scope() as session: + # Get the flow from the database flow = await session.get(Flow, flow_id) if flow: await session.delete(flow) @@ -230,7 +227,7 @@ async def test_update_project_mcp_settings_success( assert "Updated MCP settings for 1 flows" in response.json()["message"] # Verify the flow was actually updated in the database - async with session_getter(get_db_service()) as session: + async with session_scope() as session: updated_flow = await session.get(Flow, test_flow_for_update.id) assert updated_flow is not None assert updated_flow.action_name == "updated_action" @@ -271,7 +268,7 @@ async def test_update_project_mcp_settings_empty_settings(client: AsyncClient, u # Use real database objects instead of mocks to avoid the coroutine issue # Empty settings list - settings = [] + settings: list = [] # Make the request to the actual endpoint response = await client.patch( @@ -300,7 +297,7 @@ async def test_user_data_isolation_with_real_db( second_flow_id = uuid4() # Use real database session just for flow creation and cleanup - async with session_getter(get_db_service()) as session: + async with session_scope() as session: # Create a flow in the other user's project second_flow = Flow( id=second_flow_id, @@ -331,7 +328,7 @@ async def test_user_data_isolation_with_real_db( finally: # Clean up flow - async with session_getter(get_db_service()) as session: + async with session_scope() as session: second_flow = await session.get(Flow, second_flow_id) if second_flow: await session.delete(second_flow) @@ -342,15 +339,14 @@ async def test_user_data_isolation_with_real_db( async def user_test_project(active_user): """Fixture for creating a project for the active user.""" project_id = uuid4() - db_manager = get_db_service() - async with db_manager.with_session() as session: + async with session_scope() as session: project = Folder(id=project_id, name="User Test Project", user_id=active_user.id) session.add(project) await session.commit() await session.refresh(project) yield project # Clean up - async with db_manager.with_session() as session: + async with session_scope() as session: project = await session.get(Folder, project_id) if project: await session.delete(project) @@ -361,8 +357,7 @@ async def user_test_project(active_user): async def user_test_flow(active_user, user_test_project): """Fixture for creating a flow for the active user.""" flow_id = uuid4() - db_manager = get_db_service() - async with db_manager.with_session() as session: + async with session_scope() as session: flow = Flow( id=flow_id, name="User Test Flow", @@ -378,7 +373,7 @@ async def user_test_flow(active_user, user_test_project): await session.refresh(flow) yield flow # Clean up - async with db_manager.with_session() as session: + async with session_scope() as session: flow = await session.get(Flow, flow_id) if flow: await session.delete(flow) @@ -411,7 +406,7 @@ async def test_user_can_update_own_flow_mcp_settings( assert "Updated MCP settings for 1 flows" in response.json()["message"] # Verify the flow was actually updated in the database - async with session_getter(get_db_service()) as session: + async with session_scope() as session: updated_flow = await session.get(Flow, user_test_flow.id) assert updated_flow is not None assert updated_flow.action_name == "updated_user_action" diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 8458c9435..58a150f08 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -33,7 +33,6 @@ "@tailwindcss/line-clamp": "^0.4.4", "@tanstack/react-query": "^5.49.2", "@types/axios": "^0.14.0", - "@types/react-syntax-highlighter": "^15.5.13", "@xyflow/react": "^12.3.6", "ace-builds": "^1.41.0", "ag-grid-community": "^32.0.2", @@ -76,7 +75,7 @@ "react-pdf": "^9.0.0", "react-router-dom": "^6.23.1", "react-sortablejs": "^6.1.4", - "react-syntax-highlighter": "^15.5.0", + "react-syntax-highlighter": "^15.6.1", "reactflow": "^11.11.3", "rehype-mathjax": "^4.0.3", "rehype-raw": "^6.1.1", @@ -5529,14 +5528,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.13", - "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", - "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/sortablejs": { "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 35ce5bf18..4cec7b732 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -28,7 +28,6 @@ "@tailwindcss/line-clamp": "^0.4.4", "@tanstack/react-query": "^5.49.2", "@types/axios": "^0.14.0", - "@types/react-syntax-highlighter": "^15.5.13", "@xyflow/react": "^12.3.6", "ace-builds": "^1.41.0", "ag-grid-community": "^32.0.2", @@ -71,7 +70,7 @@ "react-pdf": "^9.0.0", "react-router-dom": "^6.23.1", "react-sortablejs": "^6.1.4", - "react-syntax-highlighter": "^15.5.0", + "react-syntax-highlighter": "^15.6.1", "reactflow": "^11.11.3", "rehype-mathjax": "^4.0.3", "rehype-raw": "^6.1.1", @@ -147,4 +146,4 @@ "ua-parser-js": "^1.0.38", "vite": "^5.4.19" } -} \ No newline at end of file +} diff --git a/src/frontend/src/controllers/API/queries/mcp/use-get-installed-mcp.ts b/src/frontend/src/controllers/API/queries/mcp/use-get-installed-mcp.ts new file mode 100644 index 000000000..3bdfaf7c2 --- /dev/null +++ b/src/frontend/src/controllers/API/queries/mcp/use-get-installed-mcp.ts @@ -0,0 +1,39 @@ +import { useQueryFunctionType } from "@/types/api"; +import { api } from "../../api"; +import { getURL } from "../../helpers/constants"; +import { UseRequestProcessor } from "../../services/request-processor"; + +interface IGetInstalledMCP { + projectId: string; +} + +type getInstalledMCPResponse = Array; + +export const useGetInstalledMCP: useQueryFunctionType< + IGetInstalledMCP, + getInstalledMCPResponse +> = (params, options) => { + const { query } = UseRequestProcessor(); + + const responseFn = async () => { + try { + const { data } = await api.get( + `${getURL("MCP")}/${params.projectId}/installed`, + ); + return data; + } catch (error) { + console.error(error); + return []; + } + }; + + const queryResult = query( + ["useGetInstalledMCP", params.projectId], + responseFn, + { + ...options, + }, + ); + + return queryResult; +}; diff --git a/src/frontend/src/controllers/API/queries/mcp/use-patch-install-mcp.ts b/src/frontend/src/controllers/API/queries/mcp/use-patch-install-mcp.ts new file mode 100644 index 000000000..6d349c944 --- /dev/null +++ b/src/frontend/src/controllers/API/queries/mcp/use-patch-install-mcp.ts @@ -0,0 +1,61 @@ +import { useMutationFunctionType } from "@/types/api"; +import { UseMutationResult } from "@tanstack/react-query"; +import { api } from "../../api"; +import { getURL } from "../../helpers/constants"; +import { UseRequestProcessor } from "../../services/request-processor"; + +interface PatchInstallMCPParams { + project_id: string; +} + +interface PatchInstallMCPResponse { + message: string; +} + +interface PatchInstallMCPBody { + client: string; +} + +export const usePatchInstallMCP: useMutationFunctionType< + PatchInstallMCPParams, + PatchInstallMCPBody, + PatchInstallMCPResponse +> = (params, options?) => { + const { mutate, queryClient } = UseRequestProcessor(); + + async function patchInstallMCP( + body: PatchInstallMCPBody, + ): Promise { + try { + const res = await api.post( + `${getURL("MCP")}/${params.project_id}/install`, + body, + ); + + return { message: res.data?.message || "MCP installed successfully" }; + } catch (error: any) { + // Transform the error to include a message that can be handled by the UI + const errorMessage = + error.response?.data?.detail || + error.message || + "Failed to install MCP"; + throw new Error(errorMessage); + } + } + + const mutation: UseMutationResult< + PatchInstallMCPResponse, + any, + PatchInstallMCPBody + > = mutate(["usePatchInstallMCP"], patchInstallMCP, { + ...options, + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: ["useGetInstalledMCP", params.project_id], + }); + options?.onSuccess?.(data, variables, context); + }, + }); + + return mutation; +}; diff --git a/src/frontend/src/hooks/useIsLocalConnection.ts b/src/frontend/src/hooks/useIsLocalConnection.ts new file mode 100644 index 000000000..cbbc63194 --- /dev/null +++ b/src/frontend/src/hooks/useIsLocalConnection.ts @@ -0,0 +1,18 @@ +import { useMemo } from "react"; + +/** + * Hook to check if the current window is being accessed through a local connection + * @returns A boolean indicating if the current connection is local + */ +export function useIsLocalConnection(): boolean { + return useMemo(() => { + // Get the current window's hostname + const currentHostname = window.location.hostname; + + // List of hostnames/IPs that are considered local + const localAddresses = ["localhost", "127.0.0.1", "0.0.0.0"]; + + // Check if the current hostname is in the local addresses list + return localAddresses.includes(currentHostname); + }, []); +} diff --git a/src/frontend/src/icons/fontAwesomeIcons.ts b/src/frontend/src/icons/fontAwesomeIcons.ts index 51b4896aa..7c0f835ea 100644 --- a/src/frontend/src/icons/fontAwesomeIcons.ts +++ b/src/frontend/src/icons/fontAwesomeIcons.ts @@ -1,10 +1,11 @@ import * as fa from "react-icons/fa"; -import * as faV6 from "react-icons/fa6"; export const fontAwesomeIcons = { FaApple: fa.FaApple, FaDiscord: fa.FaDiscord, FaGithub: fa.FaGithub, + FaLinux: fa.FaLinux, + FaWindows: fa.FaWindows, }; export const isFontAwesomeIcon = (name: string): boolean => { diff --git a/src/frontend/src/icons/lazyIconImports.ts b/src/frontend/src/icons/lazyIconImports.ts index 78ddf87e2..8ec3bed84 100644 --- a/src/frontend/src/icons/lazyIconImports.ts +++ b/src/frontend/src/icons/lazyIconImports.ts @@ -1,5 +1,3 @@ -import { TwelveLabsIcon } from "./TwelveLabs"; - // Export the lazy loading mapping for icons export const lazyIconsMapping = { "AI/ML": () => @@ -62,8 +60,12 @@ export const lazyIconsMapping = { })), Couchbase: () => import("@/icons/Couchbase").then((mod) => ({ default: mod.CouchbaseIcon })), + Claude: () => + import("@/icons/Claude").then((mod) => ({ default: mod.ClaudeIcon })), CrewAI: () => import("@/icons/CrewAI").then((mod) => ({ default: mod.CrewAiIcon })), + Cursor: () => + import("@/icons/Cursor").then((mod) => ({ default: mod.CursorIcon })), DeepSeek: () => import("@/icons/DeepSeek").then((mod) => ({ default: mod.DeepSeekIcon })), Dropbox: () => diff --git a/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx b/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx index 7683fb3f4..793094aa6 100644 --- a/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx +++ b/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx @@ -2,21 +2,120 @@ import { ForwardedIconComponent } from "@/components/common/genericIconComponent import ShadTooltip from "@/components/common/shadTooltipComponent"; import ToolsComponent from "@/components/core/parameterRenderComponent/components/ToolsComponent"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs-button"; import { createApiKey } from "@/controllers/API"; import { useGetFlowsMCP, usePatchFlowsMCP, } from "@/controllers/API/queries/mcp"; +import { useGetInstalledMCP } from "@/controllers/API/queries/mcp/use-get-installed-mcp"; +import { usePatchInstallMCP } from "@/controllers/API/queries/mcp/use-patch-install-mcp"; import useTheme from "@/customization/hooks/use-custom-theme"; import { customGetMCPUrl } from "@/customization/utils/custom-mcp-url"; +import { useIsLocalConnection } from "@/hooks/useIsLocalConnection"; +import useAlertStore from "@/stores/alertStore"; import useAuthStore from "@/stores/authStore"; import { useFolderStore } from "@/stores/foldersStore"; import { MCPSettingsType } from "@/types/mcp"; import { parseString } from "@/utils/stringManipulation"; -import { cn } from "@/utils/utils"; -import { useState } from "react"; +import { cn, getOS } from "@/utils/utils"; +import { memo, ReactNode, useCallback, useState } from "react"; import { useParams } from "react-router-dom"; -import SyntaxHighlighter from "react-syntax-highlighter"; +import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; + +// Define interface for MemoizedCodeTag props +interface MemoizedCodeTagProps { + children: ReactNode; + isCopied: boolean; + copyToClipboard: () => void; + isAutoLogin: boolean | null; + apiKey: string; + isGeneratingApiKey: boolean; + generateApiKey: () => void; +} + +// Memoized CodeTag to prevent re-renders when parent components re-render +const MemoizedCodeTag = memo( + ({ + children, + isCopied, + copyToClipboard, + isAutoLogin, + apiKey, + isGeneratingApiKey, + generateApiKey, + }: MemoizedCodeTagProps) => ( +
+
+ {!isAutoLogin && ( + + )} + +
+
+ {children} +
+
+ ), +); +MemoizedCodeTag.displayName = "MemoizedCodeTag"; + +const autoInstallers = [ + { + name: "cursor", + title: "Cursor", + icon: "Cursor", + }, + { + name: "claude", + title: "Claude", + icon: "Claude", + }, +]; + +const operatingSystemTabs = [ + { + name: "macoslinux", + title: "macOS/Linux", + icon: "FaApple", + }, + { + name: "windows", + title: "Windows", + icon: "FaWindows", + }, + { + name: "wsl", + title: "WSL", + icon: "FaLinux", + }, +]; const McpServerTab = ({ folderName }: { folderName: string }) => { const isDarkMode = useTheme().dark; @@ -26,12 +125,31 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { const [isCopied, setIsCopied] = useState(false); const [apiKey, setApiKey] = useState(""); const [isGeneratingApiKey, setIsGeneratingApiKey] = useState(false); + const setSuccessData = useAlertStore((state) => state.setSuccessData); + const setErrorData = useAlertStore((state) => state.setErrorData); const { data: flowsMCP } = useGetFlowsMCP({ projectId }); const { mutate: patchFlowsMCP } = usePatchFlowsMCP({ project_id: projectId }); + const { mutate: patchInstallMCP } = usePatchInstallMCP({ + project_id: projectId, + }); + + const { data: installedMCP } = useGetInstalledMCP({ projectId }); + + const [selectedPlatform, setSelectedPlatform] = useState( + operatingSystemTabs.find((tab) => tab.name.includes(getOS() || "windows")) + ?.name, + ); const isAutoLogin = useAuthStore((state) => state.autoLogin); + // Check if the current connection is local + const isLocalConnection = useIsLocalConnection(); + + const [selectedMode, setSelectedMode] = useState( + isLocalConnection ? "Auto install" : "JSON", + ); + const handleOnNewValue = (value) => { const flowsMCPData: MCPSettingsType[] = value.value.map((flow) => ({ id: flow.id, @@ -66,9 +184,18 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { const MCP_SERVER_JSON = `{ "mcpServers": { "lf-${parseString(folderName ?? "project", ["snake_case", "no_blank", "lowercase"]).slice(0, 11)}": { - "command": "uvx", + "command": "${selectedPlatform === "windows" ? "cmd" : selectedPlatform === "wsl" ? "wsl" : "uvx"}", "args": [ - "mcp-proxy",${ + ${ + selectedPlatform === `windows` + ? `"/c", + "uvx", + ` + : selectedPlatform === "wsl" + ? `"uvx", + ` + : "" + }"mcp-proxy",${ isAutoLogin ? "" : ` @@ -88,7 +215,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { const MCP_SERVER_DEPLOY_TUTORIAL_LINK = "https://docs.langflow.org/mcp-server#deploy-your-server-externally"; - const copyToClipboard = () => { + const copyToClipboard = useCallback(() => { navigator.clipboard .writeText(MCP_SERVER_JSON) .then(() => { @@ -98,9 +225,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { }, 1000); }) .catch((err) => console.error("Failed to copy text: ", err)); - }; + }, [MCP_SERVER_JSON]); - const generateApiKey = () => { + const generateApiKey = useCallback(() => { setIsGeneratingApiKey(true); createApiKey(`MCP Server ${folderName}`) .then((res) => { @@ -110,7 +237,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { .finally(() => { setIsGeneratingApiKey(false); }); - }; + }, [folderName]); + + const [loadingMCP, setLoadingMCP] = useState([]); return (
@@ -130,8 +259,8 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { Projects as MCP Servers guide.
-
-
+
+
{ />
-
-
- ( -
-
- {!isAutoLogin && ( - + ), + )} +
+
+ {selectedMode === "JSON" && ( + <> +
+ + + {operatingSystemTabs.map((tab, index) => ( + + ))} + + +
+ ( + + {children} + )} - + language="json" + > + {MCP_SERVER_JSON} + +
+
+
+ Add this config to your client of choice. Need help? See the{" "} + + setup guide + + . +
+ + )} + {selectedMode === "Auto install" && ( +
+ {!isLocalConnection && ( +
+
+ + + One-click install is disabled because the Langflow server + is not running on your local machine. Use the JSON tab to + configure your client manually. +
-
{children}
)} - language="json" - > - {MCP_SERVER_JSON} - -
-
- Add this config to your client of choice. Need help? See the{" "} - - setup guide - - . -
+ {autoInstallers.map((installer) => ( + + ))} +
+ )}
diff --git a/src/frontend/src/pages/MainPage/pages/homePage/index.tsx b/src/frontend/src/pages/MainPage/pages/homePage/index.tsx index db1efaee1..7b37bd206 100644 --- a/src/frontend/src/pages/MainPage/pages/homePage/index.tsx +++ b/src/frontend/src/pages/MainPage/pages/homePage/index.tsx @@ -104,6 +104,16 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Only track these keys when we're in list/selection mode and not when a modal is open + // or when an input field is focused + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + (e.target instanceof HTMLElement && e.target.isContentEditable) + ) { + return; + } + if (e.key === "Shift") { setIsShiftPressed(true); } else if ((!IS_MAC && e.key === "Control") || e.key === "Meta") { @@ -112,6 +122,14 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => { }; const handleKeyUp = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + (e.target instanceof HTMLElement && e.target.isContentEditable) + ) { + return; + } + if (e.key === "Shift") { setIsShiftPressed(false); } else if ((!IS_MAC && e.key === "Control") || e.key === "Meta") { @@ -125,9 +143,12 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => { setIsCtrlPressed(false); }; - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keyup", handleKeyUp); - window.addEventListener("blur", handleBlur); + // Only add listeners if we're in flows or components mode, not MCP mode + if (flowType === "flows" || flowType === "components") { + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + } // Clean up event listeners when component unmounts return () => { @@ -139,7 +160,7 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => { setIsShiftPressed(false); setIsCtrlPressed(false); }; - }, []); + }, [flowType]); const setSelectedFlow = useCallback( (selected: boolean, flowId: string, index: number) => { diff --git a/src/frontend/src/utils/utils.ts b/src/frontend/src/utils/utils.ts index 68f878f03..c002a09d5 100644 --- a/src/frontend/src/utils/utils.ts +++ b/src/frontend/src/utils/utils.ts @@ -884,3 +884,21 @@ export const formatNumber = (num: number | undefined): string => { } return num?.toString(); }; + +export function getOS() { + const platform = ( + window.navigator?.userAgentData?.platform || window.navigator.platform + ).toLowerCase(); + + let os: string | null = null; + + if (platform.includes("mac") || platform.includes("darwin")) { + os = "macos"; + } else if (platform.includes("win")) { + os = "windows"; + } else if (platform.includes("linux")) { + os = "linux"; + } + + return os; +} diff --git a/src/frontend/tests/extended/features/mcp-server-tab.spec.ts b/src/frontend/tests/extended/features/mcp-server-tab.spec.ts index 8a6d5780a..e0941b2ef 100644 --- a/src/frontend/tests/extended/features/mcp-server-tab.spec.ts +++ b/src/frontend/tests/extended/features/mcp-server-tab.spec.ts @@ -98,6 +98,8 @@ test( await element.scrollIntoViewIfNeeded(); + await page.waitForTimeout(500); + let count = 0; while ( @@ -145,6 +147,10 @@ test( // Verify the selected action is visible in the tab await expect(page.getByTestId("div-mcp-server-tools")).toBeVisible(); + await page.getByText("JSON", { exact: true }).last().click(); + + await page.waitForSelector("pre", { state: "visible", timeout: 3000 }); + // Generate API key if not in auto login mode const isAutoLogin = await page .getByText("Generate API key") @@ -166,11 +172,22 @@ test( // Extract the SSE URL from the configuration const sseUrlMatch = configJson?.match( - /"args":\s*\[\s*"mcp-proxy"\s*,\s*"([^"]+)"/, + /"args":\s*\[\s*"\/c"\s*,\s*"uvx"\s*,\s*"mcp-proxy"\s*,\s*"([^"]+)"/, ); expect(sseUrlMatch).not.toBeNull(); const sseUrl = sseUrlMatch![1]; + await page.getByText("macOS/Linux", { exact: true }).click(); + + await page.waitForSelector("pre", { state: "visible", timeout: 3000 }); + + const configJsonLinux = await page.locator("pre").textContent(); + + const sseUrlMatchLinux = configJsonLinux?.match( + /"args":\s*\[\s*"mcp-proxy"\s*,\s*"([^"]+)"/, + ); + expect(sseUrlMatchLinux).not.toBeNull(); + // Verify setup guide link await expect(page.getByText("setup guide")).toBeVisible(); await expect(page.getByText("setup guide")).toHaveAttribute(