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:
parent
21af370e5b
commit
d3d06be8e5
14 changed files with 894 additions and 275 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
11
src/frontend/package-lock.json
generated
11
src/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
18
src/frontend/src/hooks/useIsLocalConnection.ts
Normal file
18
src/frontend/src/hooks/useIsLocalConnection.ts
Normal 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);
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue