feat: add one click install to mcp servers on specific clients (#8271)

* Added endpoint to add servers to local backend

* Add install mcp query

* Fixed mcp projects to receive body schema

* Added patch install to mcp server tab

* feat: adds new Edit Details popover, removes flow menu, fixes nav alignment, adds new Flow Status overlay (#8087)

* Updated flow settings component size

* Added FlowSettingsComponent to contain modal content

* Removed unused imports

* Changed Flow Settings Modal to use new component

* Changed Flow Menu styling, removing Saved and context menu, and adding a direct click to edit flow info

* Removed unused styling

* Updated nav position and truncation

* updated alert styling

* Added z index to header

* Added flow settings coming from the bottom

* Changed flow settings to not crash when there is no flow

* Removed unused imports

* Implemented flow details using popover

* Removed onClick

* Changed canvas controls position and color

* Changed panel tooltip side and classes

* Added log canvas component

* Added children to flow logs modal

* Added log canvas component into page

* Changed position and shadow of canvas controls

* removed endpoint name from edit flow settings

* added endpoint name change into tweaks modal

* Added endpoint editing to tweaks

* Implemented storing the error in the flowBuildStatus

* Updated type

* Added Flow Building Component

* Added Flow Building Component implementation

* Added red color

* Added past build flow params

* Implemented design of flowBuildingComponent

* Implemented build error storing on flowStore

* Implemented build error on flow store

* Changed notifications test

* Set build error as null when building

* Reset build error when exiting flow

* Changed from error to buildError

* Changed flowStore to have buildInfo instead of buildError

* Changed flowBuildingComponent to have buildInfo and display successful builds

* Added handleDismissed instead of setting dismissed as true

* Updated tests to current Update implementation

* Updated tests to remove click on built successfully

* Updated tests and data-testid to match new Flow Name editing behavior

* fixed auto login test

* Fixed edit-flow-name test and save changes on node

* fixed tests

* Changed Share to Publish and added test ids

* added Rename Flow util for tests

* Changed tests to use new RenameFlow

* Fixed auto save off

* Added data test id to flow building component

* Removed pulsing from Name Invalid

* Made name editable but not saveable when invalid

* Added character name reached on description

* Added transition on pencil

* Modularized alert store to separate notification history and notifications

* Added errors to notification history

* Fixed flow building component position and update all components

* Fixed animations

* Fixed animation

* Added same animation to Update All Components

* Updated animations to make update only appear when flow building is not appearing

* fix flow settings test

* Fixed build status not being redefined

* ✨ (UpdateAllComponents/index.tsx): Refactor containerVariants to CONTAINER_VARIANTS for consistency and readability
📝 (visual-variants.ts): Add visual variants for buttons and time in flowBuildingComponent
♻️ (flowBuildingComponent/index.tsx): Import visual variants from separate file for better organization and maintainability

* Fixed offset width of time

---------

Co-authored-by: cristhianzl <cristhian.lousa@gmail.com>

* fix: fix regex on mcp server tab test (#8175)

* Fixed MCP Server Tab Test

* Fixed mcp server tab test

* Added timeout to test

* Added retry to mcp server tab test

* docs: cookie-banner-link (#8179)

cookie-banner-link

* fix: removed fit view that caused duck duck go test to fail (#8178)

fixed duck duck go test to not fail

* feat: Enhance API request component (#8070)

* update the api request component

* [autofix.ci] apply automated fixes

* update the component

* Update test_api_request_component.py

* [autofix.ci] apply automated fixes

* remove MODE_CONFIG unused variable

* [autofix.ci] apply automated fixes

* use normalize function

* Update template

* Update test_api_request_component.py

* UI test fix

* selector fix

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yuqi Tang <yuqi.tang@datastax.com>
Co-authored-by: Mike Fortman <michael.fortman@datastax.com>

* docs: system assist component (#8089)

* sidebars

* initial-content

* more

* update

* trailing-spaces

* example

* standardize-naming

* shorten-introduction-and-remove-client

* Apply suggestions from code review

Co-authored-by: April I. Murphy <36110273+aimurphy@users.noreply.github.com>

* Update docs/docs/Integrations/Nvidia/integrations-nvidia-system-assist.md

---------

Co-authored-by: April I. Murphy <36110273+aimurphy@users.noreply.github.com>

* docs: deploy langflow with caddyfile and docker compose (#8120)

* initial-content

* not-json

* clarify-public key

* more-accessible-name-and-context

* exit-session

* fix: simplify GetStartedProgress percentage calculation logic (#8183)

🐛 (get-started-progress.tsx): fix calculation of totalPercentage to correctly display progress bar percentage
💡 (get-started-progress.tsx): refactor logic to calculate totalPercentage based on user opt-ins and flows

* fix: set cursor to text in text fields (#8173)

Fixed cursor being default in input fields

* feat: add datastax components bundle (#8184)

* feat: add datastax components bundle

* Update __init__.py

* Remove old astra assistants folder

* Remove old tools

* Update __init__.py

* Update test_assistants_components.py

---------

Co-authored-by: Edwin Jose <edwin.jose@datastax.com>

* feat: updated components header styling (#8085)

* Removed unused styles

* Updated node icon to follow design

* Updated node name to follow design and include Beta

* Removed Beta from node status

* Removed unused classes and parameters from GenericNode

* Changed node description padding on input

* Changed paddings and gaps

* removed unused classes

* Added accent purple foreground color to Experimental

* Fixed classes and gaps in generic node

* Fixed node name gaps

* Fixed node status classes and styling

* Removed unused classes and changed run-bg size

* Changed test to use new test id

* Changed Node Name to have beta tooltip

* Changed Build Failed icon to be a circle alert

* Changed Node Status gap and conditions to show spacings correctly

* Changed padding to not change height of other components

* Changed nodeStatus to show validation on small node

* Changed classes to show correct spacing and overflow

* Changed description size

* Fixed description text size

* Fixed input margin

* Fixed description editing not appearing when no description is available

* Fixed status not breaking words

* Updated colors

* Updated node output color

* [autofix.ci] apply automated fixes

* Changed duration style in chat

* Re-added output color

* Updated timeout on mcp server tab test

* Added more timeout to mcp server tab test

* fixed loop component test

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* ref: SQL component (#8185)

* update sql

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* feat: Loop uplift dataframe input and output (#8177)

* tests cases

* update to loop

* Update component.py

* 📝 (LoopTemplate.json): update value of a configuration key from "OPENAI_API_KEY" to "ANTHROPIC_API_KEY" in order to reflect the correct API key being used

* update json test loop

* add dataframe support for the loop component

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* fix: starter project

* update loop component and tests

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* update logic

* Update loop_basic.py

* Update Research Translation Loop.json

* fix lint

* format fix

* [autofix.ci] apply automated fixes

* reverting changes in component and vertex base

* [autofix.ci] apply automated fixes

* fix lint errors

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* update in the loop templates and component

* [autofix.ci] apply automated fixes

* Update Research Translation Loop.json

* update tests

* update the code and deprecate the old loop

* [autofix.ci] apply automated fixes

* Update loop_basic.py

* WIP FIX Loop Tests

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes

* ✨ (loop-component.spec.ts): Update test cases to use more descriptive names for components and actions for better clarity and understanding.

* ✨ (loop-component.spec.ts): refactor loop component tests to improve readability and maintainability by updating test selectors and removing redundant test steps

* update

* Update loop-component.spec.ts

* Update Research Translation Loop.json

* Update Research Translation Loop.json

* Update Research Translation Loop.json

* Update Research Translation Loop.json

* loop test fix

---------

Co-authored-by: cristhianzl <cristhian.lousa@gmail.com>
Co-authored-by: Rodrigo <rodrigosilvanader@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: italojohnny <italojohnnydosanjos@gmail.com>
Co-authored-by: Mike Fortman <michael.fortman@datastax.com>

* fix: fixes nightly tests (#8194)

* fix: mcp test when there are a lot of flows (#8197)

* Added cursor and claude in icons

* Added one click install to cursor and claude

* Changed design of mcp server tab page

* Added function to get local platform

* Added platform specific installer json on mcp server tab

* Added FA icons for windows and linux

* Added icons to tabs

* Added endpoint to check for installed MCP servers

* Added use get installed MCP servers

* Changed to get installed

* Use installed MCP servers on server

* Correct instalation for windows and WSL

* Fixed code not selecting

* refactor: use session_scope for database session management in install_mcp_config

* refactor: change logger level from info to debug for WSL and Windows detection in install_mcp_configg

* refactor: replace subprocess with asyncio for WSL IP address retrieval in install_mcp_config

* refactor: streamline project MCP server handling and improve SSE connection management in mcp_projects.py

* refactor: remove unnecessary user dependency from project endpoints in mcp_projects.py

* refactor: unify database session management using session_scope in mcp_projects.py

* refactor: enhance project tool listing and logging in mcp_projects.py by using session_scope and changing logger level to debug

* refactor: simplify WSL detection logic in install_mcp_config by removing unnecessary variable and streamlining conditions

* Removed unused console.log

* Implemented check if Langflow is running on local machine

* Fixed backend to generate an error if trying to install from not local

* Added error handling to frontend and changed loading

* Fix check of macos

* Refactored mcp server tab test to work with new changes

* Fixed test to pass with Windows selected and check the status of Linux too

* [autofix.ci] apply automated fixes

* Changed wait for timeout for wait for selector

* Fixed path.open

* Refactor test_update_project_mcp_settings to use session_scope for database service mock

* Refactor tests in test_mcp_projects.py to utilize session_scope for database session management, improving consistency and readability.

* Updated wsl to uvx

---------

Co-authored-by: cristhianzl <cristhian.lousa@gmail.com>
Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com>
Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yuqi Tang <yuqi.tang@datastax.com>
Co-authored-by: Mike Fortman <michael.fortman@datastax.com>
Co-authored-by: April I. Murphy <36110273+aimurphy@users.noreply.github.com>
Co-authored-by: Eric Hare <ericrhare@gmail.com>
Co-authored-by: Rodrigo <rodrigosilvanader@gmail.com>
Co-authored-by: italojohnny <italojohnnydosanjos@gmail.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
Lucas Oliveira 2025-06-02 16:21:02 -03:00 • committed by GitHub
commit d3d06be8e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 894 additions and 275 deletions

View file

@ -2,8 +2,13 @@ import asyncio
import base64 import base64
import json import json
import logging import logging
import os
import platform
from asyncio.subprocess import create_subprocess_exec
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime, timezone from datetime import datetime, timezone
from ipaddress import ip_address
from pathlib import Path
from typing import Annotated from typing import Annotated
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@ -24,12 +29,12 @@ from langflow.api.v1.mcp import (
handle_mcp_errors, handle_mcp_errors,
with_db_session, 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.base.mcp.util import get_flow_snake_case
from langflow.helpers.flow import json_schema_from_flow 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.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 from langflow.services.storage.utils import build_content_type_from_extension
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,18 +56,17 @@ def get_project_sse(project_id: UUID) -> SseServerTransport:
return project_sse_transports[project_id_str] 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( async def list_project_tools(
project_id: UUID, project_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
*, *,
mcp_enabled: bool = True, mcp_enabled: bool = True,
): ) -> list[MCPSettings]:
"""List all tools in a project that are enabled for MCP.""" """List all tools in a project that are enabled for MCP."""
tools: list[MCPSettings] = [] tools: list[MCPSettings] = []
try: try:
db_service = get_db_service() async with session_scope() as session:
async with db_service.with_session() as session:
# Fetch the project first to verify it exists and belongs to the current user # Fetch the project first to verify it exists and belongs to the current user
project = ( project = (
await session.exec( await session.exec(
@ -120,6 +124,378 @@ async def list_project_tools(
return 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 # Project-specific MCP server instance for handling project-specific tools
class ProjectMCPServer: class ProjectMCPServer:
def __init__(self, project_id: UUID): def __init__(self, project_id: UUID):
@ -133,8 +509,7 @@ class ProjectMCPServer:
"""Handle listing tools for this specific project.""" """Handle listing tools for this specific project."""
tools = [] tools = []
try: try:
db_service = get_db_service() async with session_scope() as session:
async with db_service.with_session() as session:
# Get flows with mcp_enabled flag set to True and in this project # Get flows with mcp_enabled flag set to True and in this project
flows = ( flows = (
await session.exec( await session.exec(
@ -173,7 +548,6 @@ class ProjectMCPServer:
async def handle_list_resources(): async def handle_list_resources():
resources = [] resources = []
try: try:
db_service = get_db_service()
storage_service = get_storage_service() storage_service = get_storage_service()
settings_service = get_settings_service() settings_service = get_settings_service()
@ -183,7 +557,7 @@ class ProjectMCPServer:
base_url = f"http://{host}:{port}".rstrip("/") 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() flows = (await session.exec(select(Flow))).all()
for flow in flows: for flow in flows:
@ -388,8 +762,7 @@ def get_project_mcp_server(project_id: UUID) -> ProjectMCPServer:
async def init_mcp_servers(): async def init_mcp_servers():
"""Initialize MCP servers for all projects.""" """Initialize MCP servers for all projects."""
try: try:
db_service = get_db_service() async with session_scope() as session:
async with db_service.with_session() as session:
projects = (await session.exec(select(Folder))).all() projects = (await session.exec(select(Folder))).all()
for project in projects: for project in projects:
@ -404,153 +777,3 @@ async def init_mcp_servers():
except Exception as e: except Exception as e:
msg = f"Failed to initialize MCP servers: {e}" msg = f"Failed to initialize MCP servers: {e}"
logger.exception(msg) 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

View file

@ -406,3 +406,7 @@ class MCPSettings(BaseModel):
action_description: str | None = None action_description: str | None = None
name: str | None = None name: str | None = None
description: str | None = None description: str | None = None
class MCPInstallRequest(BaseModel):
client: str

View file

@ -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.flow import Flow
from langflow.services.database.models.folder import Folder from langflow.services.database.models.folder import Folder
from langflow.services.database.models.user import User from langflow.services.database.models.user import User
from langflow.services.database.utils import session_getter from langflow.services.deps import session_scope
from langflow.services.deps import get_db_service
from mcp.server.sse import SseServerTransport from mcp.server.sse import SseServerTransport
# Mark all tests in this module as asyncio # Mark all tests in this module as asyncio
@ -100,8 +99,7 @@ def mock_current_project_ctx(mock_project):
async def other_test_user(): async def other_test_user():
"""Fixture for creating another test user.""" """Fixture for creating another test user."""
user_id = uuid4() user_id = uuid4()
db_manager = get_db_service() async with session_scope() as session:
async with db_manager.with_session() as session:
user = User( user = User(
id=user_id, id=user_id,
username="other_test_user", username="other_test_user",
@ -114,7 +112,7 @@ async def other_test_user():
await session.refresh(user) await session.refresh(user)
yield user yield user
# Clean up # Clean up
async with db_manager.with_session() as session: async with session_scope() as session:
user = await session.get(User, user_id) user = await session.get(User, user_id)
if user: if user:
await session.delete(user) await session.delete(user)
@ -125,15 +123,14 @@ async def other_test_user():
async def other_test_project(other_test_user): async def other_test_project(other_test_user):
"""Fixture for creating a project for another test user.""" """Fixture for creating a project for another test user."""
project_id = uuid4() project_id = uuid4()
db_manager = get_db_service() async with session_scope() as session:
async with db_manager.with_session() as session:
project = Folder(id=project_id, name="Other Test Project", user_id=other_test_user.id) project = Folder(id=project_id, name="Other Test Project", user_id=other_test_user.id)
session.add(project) session.add(project)
await session.commit() await session.commit()
await session.refresh(project) await session.refresh(project)
yield project yield project
# Clean up # Clean up
async with db_manager.with_session() as session: async with session_scope() as session:
project = await session.get(Folder, project_id) project = await session.get(Folder, project_id)
if project: if project:
await session.delete(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 client: AsyncClient, mock_project, mock_sse_transport, logged_in_headers
): ):
"""Test successful handling of project messages.""" """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_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 mock_session.exec.return_value.first.return_value = mock_project
response = await client.post( 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): async def test_update_project_mcp_settings_invalid_json(client: AsyncClient, mock_project, logged_in_headers):
"""Test updating MCP settings with invalid JSON.""" """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_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 mock_session.exec.return_value.first.return_value = mock_project
response = await client.patch( 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 # Create the flow in the database
db_manager = get_db_service() async with session_scope() as session:
async with db_manager.with_session() as session:
flow = Flow(**flow_data) flow = Flow(**flow_data)
session.add(flow) session.add(flow)
await session.commit() await session.commit()
@ -197,7 +193,8 @@ async def test_flow_for_update(active_user, user_test_project):
yield flow yield flow
# Clean up # 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) flow = await session.get(Flow, flow_id)
if flow: if flow:
await session.delete(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"] assert "Updated MCP settings for 1 flows" in response.json()["message"]
# Verify the flow was actually updated in the database # 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) updated_flow = await session.get(Flow, test_flow_for_update.id)
assert updated_flow is not None assert updated_flow is not None
assert updated_flow.action_name == "updated_action" 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 # Use real database objects instead of mocks to avoid the coroutine issue
# Empty settings list # Empty settings list
settings = [] settings: list = []
# Make the request to the actual endpoint # Make the request to the actual endpoint
response = await client.patch( response = await client.patch(
@ -300,7 +297,7 @@ async def test_user_data_isolation_with_real_db(
second_flow_id = uuid4() second_flow_id = uuid4()
# Use real database session just for flow creation and cleanup # 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 # Create a flow in the other user's project
second_flow = Flow( second_flow = Flow(
id=second_flow_id, id=second_flow_id,
@ -331,7 +328,7 @@ async def test_user_data_isolation_with_real_db(
finally: finally:
# Clean up flow # 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) second_flow = await session.get(Flow, second_flow_id)
if second_flow: if second_flow:
await session.delete(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): async def user_test_project(active_user):
"""Fixture for creating a project for the active user.""" """Fixture for creating a project for the active user."""
project_id = uuid4() project_id = uuid4()
db_manager = get_db_service() async with session_scope() as session:
async with db_manager.with_session() as session:
project = Folder(id=project_id, name="User Test Project", user_id=active_user.id) project = Folder(id=project_id, name="User Test Project", user_id=active_user.id)
session.add(project) session.add(project)
await session.commit() await session.commit()
await session.refresh(project) await session.refresh(project)
yield project yield project
# Clean up # Clean up
async with db_manager.with_session() as session: async with session_scope() as session:
project = await session.get(Folder, project_id) project = await session.get(Folder, project_id)
if project: if project:
await session.delete(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): async def user_test_flow(active_user, user_test_project):
"""Fixture for creating a flow for the active user.""" """Fixture for creating a flow for the active user."""
flow_id = uuid4() flow_id = uuid4()
db_manager = get_db_service() async with session_scope() as session:
async with db_manager.with_session() as session:
flow = Flow( flow = Flow(
id=flow_id, id=flow_id,
name="User Test Flow", name="User Test Flow",
@ -378,7 +373,7 @@ async def user_test_flow(active_user, user_test_project):
await session.refresh(flow) await session.refresh(flow)
yield flow yield flow
# Clean up # Clean up
async with db_manager.with_session() as session: async with session_scope() as session:
flow = await session.get(Flow, flow_id) flow = await session.get(Flow, flow_id)
if flow: if flow:
await session.delete(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"] assert "Updated MCP settings for 1 flows" in response.json()["message"]
# Verify the flow was actually updated in the database # 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) updated_flow = await session.get(Flow, user_test_flow.id)
assert updated_flow is not None assert updated_flow is not None
assert updated_flow.action_name == "updated_user_action" assert updated_flow.action_name == "updated_user_action"

View file

@ -33,7 +33,6 @@
"@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/react-query": "^5.49.2", "@tanstack/react-query": "^5.49.2",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.3.6", "@xyflow/react": "^12.3.6",
"ace-builds": "^1.41.0", "ace-builds": "^1.41.0",
"ag-grid-community": "^32.0.2", "ag-grid-community": "^32.0.2",
@ -76,7 +75,7 @@
"react-pdf": "^9.0.0", "react-pdf": "^9.0.0",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"react-sortablejs": "^6.1.4", "react-sortablejs": "^6.1.4",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.6.1",
"reactflow": "^11.11.3", "reactflow": "^11.11.3",
"rehype-mathjax": "^4.0.3", "rehype-mathjax": "^4.0.3",
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
@ -5529,14 +5528,6 @@
"@types/react": "^18.0.0" "@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": { "node_modules/@types/sortablejs": {
"version": "1.15.8", "version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz",

View file

@ -28,7 +28,6 @@
"@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/react-query": "^5.49.2", "@tanstack/react-query": "^5.49.2",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.3.6", "@xyflow/react": "^12.3.6",
"ace-builds": "^1.41.0", "ace-builds": "^1.41.0",
"ag-grid-community": "^32.0.2", "ag-grid-community": "^32.0.2",
@ -71,7 +70,7 @@
"react-pdf": "^9.0.0", "react-pdf": "^9.0.0",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"react-sortablejs": "^6.1.4", "react-sortablejs": "^6.1.4",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.6.1",
"reactflow": "^11.11.3", "reactflow": "^11.11.3",
"rehype-mathjax": "^4.0.3", "rehype-mathjax": "^4.0.3",
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",

View file

@ -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<string>;
export const useGetInstalledMCP: useQueryFunctionType<
IGetInstalledMCP,
getInstalledMCPResponse
> = (params, options) => {
const { query } = UseRequestProcessor();
const responseFn = async () => {
try {
const { data } = await api.get<getInstalledMCPResponse>(
`${getURL("MCP")}/${params.projectId}/installed`,
);
return data;
} catch (error) {
console.error(error);
return [];
}
};
const queryResult = query(
["useGetInstalledMCP", params.projectId],
responseFn,
{
...options,
},
);
return queryResult;
};

View file

@ -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<PatchInstallMCPResponse> {
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;
};

View file

@ -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);
}, []);
}

View file

@ -1,10 +1,11 @@
import * as fa from "react-icons/fa"; import * as fa from "react-icons/fa";
import * as faV6 from "react-icons/fa6";
export const fontAwesomeIcons = { export const fontAwesomeIcons = {
FaApple: fa.FaApple, FaApple: fa.FaApple,
FaDiscord: fa.FaDiscord, FaDiscord: fa.FaDiscord,
FaGithub: fa.FaGithub, FaGithub: fa.FaGithub,
FaLinux: fa.FaLinux,
FaWindows: fa.FaWindows,
}; };
export const isFontAwesomeIcon = (name: string): boolean => { export const isFontAwesomeIcon = (name: string): boolean => {

View file

@ -1,5 +1,3 @@
import { TwelveLabsIcon } from "./TwelveLabs";
// Export the lazy loading mapping for icons // Export the lazy loading mapping for icons
export const lazyIconsMapping = { export const lazyIconsMapping = {
"AI/ML": () => "AI/ML": () =>
@ -62,8 +60,12 @@ export const lazyIconsMapping = {
})), })),
Couchbase: () => Couchbase: () =>
import("@/icons/Couchbase").then((mod) => ({ default: mod.CouchbaseIcon })), import("@/icons/Couchbase").then((mod) => ({ default: mod.CouchbaseIcon })),
Claude: () =>
import("@/icons/Claude").then((mod) => ({ default: mod.ClaudeIcon })),
CrewAI: () => CrewAI: () =>
import("@/icons/CrewAI").then((mod) => ({ default: mod.CrewAiIcon })), import("@/icons/CrewAI").then((mod) => ({ default: mod.CrewAiIcon })),
Cursor: () =>
import("@/icons/Cursor").then((mod) => ({ default: mod.CursorIcon })),
DeepSeek: () => DeepSeek: () =>
import("@/icons/DeepSeek").then((mod) => ({ default: mod.DeepSeekIcon })), import("@/icons/DeepSeek").then((mod) => ({ default: mod.DeepSeekIcon })),
Dropbox: () => Dropbox: () =>

View file

@ -2,21 +2,120 @@ import { ForwardedIconComponent } from "@/components/common/genericIconComponent
import ShadTooltip from "@/components/common/shadTooltipComponent"; import ShadTooltip from "@/components/common/shadTooltipComponent";
import ToolsComponent from "@/components/core/parameterRenderComponent/components/ToolsComponent"; import ToolsComponent from "@/components/core/parameterRenderComponent/components/ToolsComponent";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs-button";
import { createApiKey } from "@/controllers/API"; import { createApiKey } from "@/controllers/API";
import { import {
useGetFlowsMCP, useGetFlowsMCP,
usePatchFlowsMCP, usePatchFlowsMCP,
} from "@/controllers/API/queries/mcp"; } 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 useTheme from "@/customization/hooks/use-custom-theme";
import { customGetMCPUrl } from "@/customization/utils/custom-mcp-url"; import { customGetMCPUrl } from "@/customization/utils/custom-mcp-url";
import { useIsLocalConnection } from "@/hooks/useIsLocalConnection";
import useAlertStore from "@/stores/alertStore";
import useAuthStore from "@/stores/authStore"; import useAuthStore from "@/stores/authStore";
import { useFolderStore } from "@/stores/foldersStore"; import { useFolderStore } from "@/stores/foldersStore";
import { MCPSettingsType } from "@/types/mcp"; import { MCPSettingsType } from "@/types/mcp";
import { parseString } from "@/utils/stringManipulation"; import { parseString } from "@/utils/stringManipulation";
import { cn } from "@/utils/utils"; import { cn, getOS } from "@/utils/utils";
import { useState } from "react"; import { memo, ReactNode, useCallback, useState } from "react";
import { useParams } from "react-router-dom"; 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) => (
<div className="relative bg-background text-[13px]">
<div className="absolute right-4 top-4 flex items-center gap-6">
{!isAutoLogin && (
<Button
unstyled
className="flex items-center gap-2 font-sans text-muted-foreground hover:text-foreground"
disabled={apiKey !== ""}
loading={isGeneratingApiKey}
onClick={generateApiKey}
>
<ForwardedIconComponent
name={"key"}
className="h-4 w-4"
aria-hidden="true"
/>
<span>
{apiKey === "" ? "Generate API key" : "API key generated"}
</span>
</Button>
)}
<Button
unstyled
size="icon"
className={cn("h-4 w-4 text-muted-foreground hover:text-foreground")}
onClick={copyToClipboard}
>
<ForwardedIconComponent
name={isCopied ? "check" : "copy"}
className="h-4 w-4"
aria-hidden="true"
/>
</Button>
</div>
<div className="overflow-x-auto p-4">
<span>{children}</span>
</div>
</div>
),
);
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 McpServerTab = ({ folderName }: { folderName: string }) => {
const isDarkMode = useTheme().dark; const isDarkMode = useTheme().dark;
@ -26,12 +125,31 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [apiKey, setApiKey] = useState<string>(""); const [apiKey, setApiKey] = useState<string>("");
const [isGeneratingApiKey, setIsGeneratingApiKey] = useState(false); const [isGeneratingApiKey, setIsGeneratingApiKey] = useState(false);
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const setErrorData = useAlertStore((state) => state.setErrorData);
const { data: flowsMCP } = useGetFlowsMCP({ projectId }); const { data: flowsMCP } = useGetFlowsMCP({ projectId });
const { mutate: patchFlowsMCP } = usePatchFlowsMCP({ project_id: 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); 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 handleOnNewValue = (value) => {
const flowsMCPData: MCPSettingsType[] = value.value.map((flow) => ({ const flowsMCPData: MCPSettingsType[] = value.value.map((flow) => ({
id: flow.id, id: flow.id,
@ -66,9 +184,18 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
const MCP_SERVER_JSON = `{ const MCP_SERVER_JSON = `{
"mcpServers": { "mcpServers": {
"lf-${parseString(folderName ?? "project", ["snake_case", "no_blank", "lowercase"]).slice(0, 11)}": { "lf-${parseString(folderName ?? "project", ["snake_case", "no_blank", "lowercase"]).slice(0, 11)}": {
"command": "uvx", "command": "${selectedPlatform === "windows" ? "cmd" : selectedPlatform === "wsl" ? "wsl" : "uvx"}",
"args": [ "args": [
"mcp-proxy",${ ${
selectedPlatform === `windows`
? `"/c",
"uvx",
`
: selectedPlatform === "wsl"
? `"uvx",
`
: ""
}"mcp-proxy",${
isAutoLogin isAutoLogin
? "" ? ""
: ` : `
@ -88,7 +215,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
const MCP_SERVER_DEPLOY_TUTORIAL_LINK = const MCP_SERVER_DEPLOY_TUTORIAL_LINK =
"https://docs.langflow.org/mcp-server#deploy-your-server-externally"; "https://docs.langflow.org/mcp-server#deploy-your-server-externally";
const copyToClipboard = () => { const copyToClipboard = useCallback(() => {
navigator.clipboard navigator.clipboard
.writeText(MCP_SERVER_JSON) .writeText(MCP_SERVER_JSON)
.then(() => { .then(() => {
@ -98,9 +225,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
}, 1000); }, 1000);
}) })
.catch((err) => console.error("Failed to copy text: ", err)); .catch((err) => console.error("Failed to copy text: ", err));
}; }, [MCP_SERVER_JSON]);
const generateApiKey = () => { const generateApiKey = useCallback(() => {
setIsGeneratingApiKey(true); setIsGeneratingApiKey(true);
createApiKey(`MCP Server ${folderName}`) createApiKey(`MCP Server ${folderName}`)
.then((res) => { .then((res) => {
@ -110,7 +237,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
.finally(() => { .finally(() => {
setIsGeneratingApiKey(false); setIsGeneratingApiKey(false);
}); });
}; }, [folderName]);
const [loadingMCP, setLoadingMCP] = useState<string[]>([]);
return ( return (
<div> <div>
@ -130,8 +259,8 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
Projects as MCP Servers guide. Projects as MCP Servers guide.
</a> </a>
</div> </div>
<div className="flex flex-row"> <div className="flex flex-col justify-between gap-8 xl:flex-row">
<div className="w-1/3"> <div className="w-full xl:w-2/5">
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<ShadTooltip <ShadTooltip
content="Flows in this project can be exposed as callable MCP actions." content="Flows in this project can be exposed as callable MCP actions."
@ -161,57 +290,72 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
/> />
</div> </div>
</div> </div>
<div className="w-2/3 pl-4"> <div className="flex flex-1 flex-col gap-4 overflow-hidden">
<div className="flex flex-col">
<div className="flex flex-row justify-start border-b border-border">
{[{ name: "Auto install" }, { name: "JSON" }].map(
(item, index) => (
<Button
unstyled
key={item.name}
className={`flex h-6 flex-row items-end gap-2 text-nowrap border-b-2 border-border border-b-transparent !py-1 font-medium ${
selectedMode === item.name
? "border-b-2 border-black dark:border-b-white"
: "text-muted-foreground hover:text-foreground"
} px-3 py-2 text-[13px]`}
onClick={() => setSelectedMode(item.name)}
>
<span>{item.name}</span>
</Button>
),
)}
</div>
</div>
{selectedMode === "JSON" && (
<>
<div className="flex flex-col gap-4">
<Tabs
value={selectedPlatform}
onValueChange={setSelectedPlatform}
>
<TabsList>
{operatingSystemTabs.map((tab, index) => (
<TabsTrigger
className="flex items-center gap-2"
key={index}
value={tab.name}
>
<ForwardedIconComponent
name={tab.icon}
aria-hidden="true"
/>
{tab.title}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="overflow-hidden rounded-lg border border-border"> <div className="overflow-hidden rounded-lg border border-border">
<SyntaxHighlighter <SyntaxHighlighter
style={syntaxHighlighterStyle} style={syntaxHighlighterStyle}
CodeTag={({ children }) => ( CodeTag={({ children }) => (
<div className="relative bg-background text-[13px]"> <MemoizedCodeTag
<div className="absolute right-4 top-4 flex items-center gap-6"> isCopied={isCopied}
{!isAutoLogin && ( copyToClipboard={copyToClipboard}
<Button isAutoLogin={isAutoLogin}
unstyled apiKey={apiKey}
className="flex items-center gap-2 font-sans text-muted-foreground hover:text-foreground" isGeneratingApiKey={isGeneratingApiKey}
disabled={apiKey !== ""} generateApiKey={generateApiKey}
loading={isGeneratingApiKey}
onClick={generateApiKey}
> >
<ForwardedIconComponent {children}
name={"key"} </MemoizedCodeTag>
className="h-4 w-4"
aria-hidden="true"
/>
<span>
{apiKey === ""
? "Generate API key"
: "API key generated"}
</span>
</Button>
)}
<Button
unstyled
size="icon"
className={cn(
"h-4 w-4 text-muted-foreground hover:text-foreground",
)}
onClick={copyToClipboard}
>
<ForwardedIconComponent
name={isCopied ? "check" : "copy"}
className="h-4 w-4"
aria-hidden="true"
/>
</Button>
</div>
<div className="overflow-x-auto p-4">{children}</div>
</div>
)} )}
language="json" language="json"
> >
{MCP_SERVER_JSON} {MCP_SERVER_JSON}
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
<div className="p-2 text-mmd text-muted-foreground"> </div>
<div className="px-2 text-mmd text-muted-foreground">
Add this config to your client of choice. Need help? See the{" "} Add this config to your client of choice. Need help? See the{" "}
<a <a
href={MCP_SERVER_TUTORIAL_LINK} href={MCP_SERVER_TUTORIAL_LINK}
@ -223,6 +367,92 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
</a> </a>
. .
</div> </div>
</>
)}
{selectedMode === "Auto install" && (
<div className="flex flex-col gap-1">
{!isLocalConnection && (
<div className="mb-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950 dark:text-amber-200">
<div className="flex items-center gap-3">
<ForwardedIconComponent
name="AlertTriangle"
className="h-4 w-4 shrink-0"
/>
<span>
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.
</span>
</div>
</div>
)}
{autoInstallers.map((installer) => (
<Button
variant="ghost"
className="flex items-center justify-between disabled:text-foreground disabled:opacity-50"
disabled={
installedMCP?.includes(installer.name) ||
loadingMCP.includes(installer.name) ||
!isLocalConnection
}
onClick={() => {
setLoadingMCP([...loadingMCP, installer.name]);
patchInstallMCP(
{
client: installer.name,
},
{
onSuccess: () => {
setSuccessData({
title: `MCP Server installed successfully on ${installer.title}`,
});
setLoadingMCP(
loadingMCP.filter(
(name) => name !== installer.name,
),
);
},
onError: (e) => {
setErrorData({
title: `Failed to install MCP Server on ${installer.title}`,
list: [e.message],
});
setLoadingMCP(
loadingMCP.filter(
(name) => name !== installer.name,
),
);
},
},
);
}}
>
<div className="flex items-center gap-4 text-sm font-medium">
<ForwardedIconComponent
name={installer.icon}
className={cn("h-5 w-5")}
aria-hidden="true"
/>
{installer.title}
</div>
<ForwardedIconComponent
name={
installedMCP?.includes(installer.name)
? "Check"
: loadingMCP.includes(installer.name)
? "Loader2"
: "Plus"
}
className={cn(
"h-4 w-4",
loadingMCP.includes(installer.name) && "animate-spin",
)}
/>
</Button>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -104,6 +104,16 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { 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") { if (e.key === "Shift") {
setIsShiftPressed(true); setIsShiftPressed(true);
} else if ((!IS_MAC && e.key === "Control") || e.key === "Meta") { } 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) => { 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") { if (e.key === "Shift") {
setIsShiftPressed(false); setIsShiftPressed(false);
} else if ((!IS_MAC && e.key === "Control") || e.key === "Meta") { } else if ((!IS_MAC && e.key === "Control") || e.key === "Meta") {
@ -125,9 +143,12 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
setIsCtrlPressed(false); setIsCtrlPressed(false);
}; };
// 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("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp); document.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur); window.addEventListener("blur", handleBlur);
}
// Clean up event listeners when component unmounts // Clean up event listeners when component unmounts
return () => { return () => {
@ -139,7 +160,7 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
setIsShiftPressed(false); setIsShiftPressed(false);
setIsCtrlPressed(false); setIsCtrlPressed(false);
}; };
}, []); }, [flowType]);
const setSelectedFlow = useCallback( const setSelectedFlow = useCallback(
(selected: boolean, flowId: string, index: number) => { (selected: boolean, flowId: string, index: number) => {

View file

@ -884,3 +884,21 @@ export const formatNumber = (num: number | undefined): string => {
} }
return num?.toString(); 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;
}

View file

@ -98,6 +98,8 @@ test(
await element.scrollIntoViewIfNeeded(); await element.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
let count = 0; let count = 0;
while ( while (
@ -145,6 +147,10 @@ test(
// Verify the selected action is visible in the tab // Verify the selected action is visible in the tab
await expect(page.getByTestId("div-mcp-server-tools")).toBeVisible(); 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 // Generate API key if not in auto login mode
const isAutoLogin = await page const isAutoLogin = await page
.getByText("Generate API key") .getByText("Generate API key")
@ -166,11 +172,22 @@ test(
// Extract the SSE URL from the configuration // Extract the SSE URL from the configuration
const sseUrlMatch = configJson?.match( 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(); expect(sseUrlMatch).not.toBeNull();
const sseUrl = sseUrlMatch![1]; 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 // Verify setup guide link
await expect(page.getByText("setup guide")).toBeVisible(); await expect(page.getByText("setup guide")).toBeVisible();
await expect(page.getByText("setup guide")).toHaveAttribute( await expect(page.getByText("setup guide")).toHaveAttribute(