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 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

View file

@ -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

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.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"

View file

@ -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",

View file

@ -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"
}
}
}

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 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 => {

View file

@ -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: () =>

View file

@ -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) => (
<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 isDarkMode = useTheme().dark;
@ -26,12 +125,31 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
const [isCopied, setIsCopied] = useState(false);
const [apiKey, setApiKey] = useState<string>("");
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<string[]>([]);
return (
<div>
@ -130,8 +259,8 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
Projects as MCP Servers guide.
</a>
</div>
<div className="flex flex-row">
<div className="w-1/3">
<div className="flex flex-col justify-between gap-8 xl:flex-row">
<div className="w-full xl:w-2/5">
<div className="flex flex-row justify-between">
<ShadTooltip
content="Flows in this project can be exposed as callable MCP actions."
@ -161,68 +290,169 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
/>
</div>
</div>
<div className="w-2/3 pl-4">
<div className="overflow-hidden rounded-lg border border-border">
<SyntaxHighlighter
style={syntaxHighlighterStyle}
CodeTag={({ children }) => (
<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}
<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={"key"}
className="h-4 w-4"
name={tab.icon}
aria-hidden="true"
/>
<span>
{apiKey === ""
? "Generate API key"
: "API key generated"}
</span>
</Button>
{tab.title}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="overflow-hidden rounded-lg border border-border">
<SyntaxHighlighter
style={syntaxHighlighterStyle}
CodeTag={({ children }) => (
<MemoizedCodeTag
isCopied={isCopied}
copyToClipboard={copyToClipboard}
isAutoLogin={isAutoLogin}
apiKey={apiKey}
isGeneratingApiKey={isGeneratingApiKey}
generateApiKey={generateApiKey}
>
{children}
</MemoizedCodeTag>
)}
<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>
language="json"
>
{MCP_SERVER_JSON}
</SyntaxHighlighter>
</div>
</div>
<div className="px-2 text-mmd text-muted-foreground">
Add this config to your client of choice. Need help? See the{" "}
<a
href={MCP_SERVER_TUTORIAL_LINK}
target="_blank"
rel="noreferrer"
className="text-accent-pink-foreground"
>
setup guide
</a>
.
</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 className="overflow-x-auto p-4">{children}</div>
</div>
)}
language="json"
>
{MCP_SERVER_JSON}
</SyntaxHighlighter>
</div>
<div className="p-2 text-mmd text-muted-foreground">
Add this config to your client of choice. Need help? See the{" "}
<a
href={MCP_SERVER_TUTORIAL_LINK}
target="_blank"
rel="noreferrer"
className="text-accent-pink-foreground"
>
setup guide
</a>
.
</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>

View file

@ -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) => {

View file

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

View file

@ -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(