From 795c62ae2262171fcc121234a8ac35b17a0ee082 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:13:07 -0300 Subject: [PATCH] feat: adds api key functionality onto mcp composer (#9498) * Added API key generation message, removed API key field from backend * Added api key generation to mcp server tab * Generate API key if project is configured to have API key auth * removed isautologin logic * Updated logic on frontend for when feature flag is disbalewd * Added should generate api key on backend * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * Changed line clamp of success message * Added reinstall notice to auth modal * Changed mcp auth on mcp_projects, allow reinstall on clients * Allow reinstall of clients * [autofix.ci] apply automated fixes * Changed copies * Removed unused logger * [autofix.ci] apply automated fixes * Added type annotation * Added removed servers type annotation * Passed unauthorized to test * Fixed mcp projects to use already existing auth when not api key and not auth_settings * Updated tests to use user_test_project * Updated typing * updated token * Removed part that unauthenticated if no token is available --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../base/langflow/api/v1/mcp_projects.py | 376 +++++++++++++++--- src/backend/base/langflow/api/v1/schemas.py | 2 - .../tests/unit/api/v1/test_mcp_projects.py | 36 +- src/frontend/src/alerts/success/index.tsx | 2 +- src/frontend/src/modals/authModal/index.tsx | 71 +++- .../homePage/components/McpServerTab.tsx | 110 +++-- src/frontend/src/types/mcp/index.ts | 5 - src/frontend/src/utils/stringManipulation.ts | 2 +- 8 files changed, 468 insertions(+), 136 deletions(-) diff --git a/src/backend/base/langflow/api/v1/mcp_projects.py b/src/backend/base/langflow/api/v1/mcp_projects.py index 4af31d5cf..00ada2431 100644 --- a/src/backend/base/langflow/api/v1/mcp_projects.py +++ b/src/backend/base/langflow/api/v1/mcp_projects.py @@ -8,10 +8,11 @@ from datetime import datetime, timezone from ipaddress import ip_address from pathlib import Path from subprocess import CalledProcessError +from typing import Annotated from uuid import UUID from anyio import BrokenResourceError -from fastapi import APIRouter, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import HTMLResponse from mcp import types from mcp.server import NotificationOptions, Server @@ -28,15 +29,121 @@ from langflow.api.v1.mcp_utils import ( handle_mcp_errors, handle_read_resource, ) -from langflow.api.v1.schemas import MCPInstallRequest, MCPProjectResponse, MCPProjectUpdateRequest, MCPSettings +from langflow.api.v1.schemas import ( + AuthSettings, + MCPInstallRequest, + MCPProjectResponse, + MCPProjectUpdateRequest, + MCPSettings, +) from langflow.base.mcp.constants import MAX_MCP_SERVER_NAME_LENGTH from langflow.base.mcp.util import sanitize_mcp_name from langflow.logging import logger from langflow.services.database.models import Flow, Folder +from langflow.services.database.models.api_key.crud import check_key, create_api_key +from langflow.services.database.models.api_key.model import ApiKeyCreate +from langflow.services.database.models.user.model import User from langflow.services.deps import get_settings_service, session_scope +from langflow.services.settings.feature_flags import FEATURE_FLAGS router = APIRouter(prefix="/mcp/project", tags=["mcp_projects"]) + +async def verify_project_auth( + project_id: UUID, + query_param: str | None = None, + header_param: str | None = None, +) -> User: + """Custom authentication for MCP project endpoints when API key is required. + + This is only used when MCP composer is enabled and project requires API key auth. + """ + async with session_scope() as session: + # First, get the project to check its auth settings + project = (await session.exec(select(Folder).where(Folder.id == project_id))).first() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # For MCP composer enabled, only use API key + api_key = query_param or header_param + if not api_key: + raise HTTPException( + status_code=401, + detail="API key required for this project. Provide x-api-key header or query parameter.", + ) + + # Validate the API key + user = await check_key(session, api_key) + if not user: + raise HTTPException(status_code=401, detail="Invalid API key") + + # Verify user has access to the project + project_access = ( + await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == user.id)) + ).first() + + if not project_access: + raise HTTPException(status_code=403, detail="Access denied to this project") + + return user + + +# Smart authentication dependency that chooses method based on project settings +async def verify_project_auth_conditional( + project_id: UUID, + request: Request, +) -> User: + """Choose authentication method based on project settings. + + - MCP Composer enabled + API key auth: Only allow API keys + - All other cases: Use standard MCP auth (JWT + API keys) + """ + async with session_scope() as session: + # Get project to check auth settings + project = (await session.exec(select(Folder).where(Folder.id == project_id))).first() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if this project requires API key only authentication + if FEATURE_FLAGS.mcp_composer and project.auth_settings: + auth_settings = AuthSettings(**project.auth_settings) + if auth_settings.auth_type == "apikey": + # For MCP composer projects with API key auth, use custom API key validation + api_key_header_value = request.headers.get("x-api-key") + api_key_query_value = request.query_params.get("x-api-key") + return await verify_project_auth(project_id, api_key_query_value, api_key_header_value) + + # For all other cases, use standard MCP authentication (allows JWT + API keys) + # Extract token + token: str | None = None + auth_header = request.headers.get("authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:] + + # Extract API keys + api_key_query_value = request.query_params.get("x-api-key") + api_key_header_value = request.headers.get("x-api-key") + + # Call the MCP auth function directly + from langflow.services.auth.utils import get_current_user_mcp + + user = await get_current_user_mcp( + token=token or "", query_param=api_key_query_value, header_param=api_key_header_value, db=session + ) + + # Verify project access + project_access = ( + await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == user.id)) + ).first() + + if not project_access: + raise HTTPException(status_code=404, detail="Project not found") + + return user + + # Create project-specific context variable current_project_ctx: ContextVar[UUID | None] = ContextVar("current_project_ctx", default=None) @@ -115,8 +222,6 @@ async def list_project_tools( # Get project-level auth settings auth_settings = None if project.auth_settings: - from langflow.api.v1.schemas import AuthSettings - auth_settings = AuthSettings(**project.auth_settings) except Exception as e: @@ -136,18 +241,9 @@ async def im_alive(project_id: str): # noqa: ARG001 async def handle_project_sse( project_id: UUID, request: Request, - current_user: CurrentActiveMCPUser, + current_user: Annotated[User, Depends(verify_project_auth_conditional)], ): """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) @@ -187,17 +283,12 @@ async def handle_project_sse( @router.post("/{project_id}") -async def handle_project_messages(project_id: UUID, request: Request, current_user: CurrentActiveMCPUser): +async def handle_project_messages( + project_id: UUID, + request: Request, + current_user: Annotated[User, Depends(verify_project_auth_conditional)], +): """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) @@ -214,7 +305,11 @@ async def handle_project_messages(project_id: UUID, request: Request, current_us @router.post("/{project_id}/") -async def handle_project_messages_with_slash(project_id: UUID, request: Request, current_user: CurrentActiveMCPUser): +async def handle_project_messages_with_slash( + project_id: UUID, + request: Request, + current_user: Annotated[User, Depends(verify_project_auth_conditional)], +): """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) @@ -340,6 +435,7 @@ async def install_mcp_config( if not is_local_ip(client_ip): raise HTTPException(status_code=500, detail="MCP configuration can only be installed from a local connection") + removed_servers: list[str] = [] # Track removed servers for reinstallation try: # Verify project exists and user has access async with session_scope() as session: @@ -350,6 +446,28 @@ async def install_mcp_config( if not project: raise HTTPException(status_code=404, detail="Project not found") + # Check if project requires API key authentication and generate if needed + generated_api_key = None + + # Determine if we need to generate an API key based on feature flag + should_generate_api_key = False + if not FEATURE_FLAGS.mcp_composer: + # When MCP_COMPOSER is disabled, only generate API key if autologin is disabled + # (matches frontend !isAutoLogin check) + settings_service = get_settings_service() + should_generate_api_key = not settings_service.auth_settings.AUTO_LOGIN + elif project.auth_settings: + # When MCP_COMPOSER is enabled, only generate if auth_type is "apikey" + auth_settings = AuthSettings(**project.auth_settings) + should_generate_api_key = auth_settings.auth_type == "apikey" + + if should_generate_api_key: + # Generate API key with specific name format + api_key_name = f"MCP Project {project.name} - {body.client}" + api_key_create = ApiKeyCreate(name=api_key_name) + unmasked_api_key = await create_api_key(session, api_key_create, current_user.id) + generated_api_key = unmasked_api_key.api_key + # Get settings service to build the SSE URL settings_service = get_settings_service() host = getattr(settings_service.settings, "host", "localhost") @@ -389,8 +507,25 @@ async def install_mcp_config( sse_url = sse_url.replace(f"http://{host}:{port}", f"http://{wsl_ip}:{port}") except OSError as e: await logger.awarning("Failed to get WSL IP address: %s. Using default URL.", str(e)) - else: - args = ["mcp-proxy", sse_url] + + # Build the base args for mcp-proxy + args = ["mcp-proxy"] + + # Add authentication args based on MCP_COMPOSER feature flag and auth settings + if not FEATURE_FLAGS.mcp_composer: + # When MCP_COMPOSER is disabled, only use headers format if API key was generated + # (when autologin is disabled) + if generated_api_key: + args.extend(["--headers", "x-api-key", generated_api_key]) + elif project.auth_settings: + # When MCP_COMPOSER is enabled, only add headers if auth_type is "apikey" + auth_settings = AuthSettings(**project.auth_settings) + if auth_settings.auth_type == "apikey" and generated_api_key: + args.extend(["--headers", "x-api-key", generated_api_key]) + # If no auth_settings or auth_type is "none", don't add any auth headers + + # Add the SSE URL + args.append(sse_url) if os_type == "Windows": command = "cmd" @@ -487,9 +622,18 @@ async def install_mcp_config( # If file exists but is invalid JSON, start fresh existing_config = {"mcpServers": {}} - # Merge new config with existing config + # Ensure mcpServers section exists if "mcpServers" not in existing_config: existing_config["mcpServers"] = {} + + # Remove any existing servers with the same SSE URL (for reinstalling) + project_sse_url = await get_project_sse_url(project_id) + existing_config, removed_servers = remove_server_by_sse_url(existing_config, project_sse_url) + + if removed_servers: + logger.info("Removed existing MCP servers with same SSE URL for reinstall: %s", removed_servers) + + # Merge new config with existing config existing_config["mcpServers"].update(mcp_config["mcpServers"]) # Write the updated config @@ -501,7 +645,13 @@ async def install_mcp_config( await logger.aexception(msg) raise HTTPException(status_code=500, detail=str(e)) from e else: - message = f"Successfully installed MCP configuration for {body.client}" + action = "reinstalled" if removed_servers else "installed" + message = f"Successfully {action} MCP configuration for {body.client}" + if removed_servers: + message += f" (replaced existing servers: {', '.join(removed_servers)})" + if generated_api_key: + auth_type = "API key" if FEATURE_FLAGS.mcp_composer else "legacy API key" + message += f" with {auth_type} authentication (key name: 'MCP Project {project.name} - {body.client}')" await logger.ainfo(message) return {"message": message} @@ -522,12 +672,11 @@ async def check_installed_mcp_servers( if not project: raise HTTPException(status_code=404, detail="Project not found") - # Project server name pattern (must match the logic in install function) - name = project.name - project_server_name = f"lf-{sanitize_mcp_name(name)[: (MAX_MCP_SERVER_NAME_LENGTH - 4)]}" + # Generate the SSE URL for this project + project_sse_url = await get_project_sse_url(project_id) await logger.adebug( - "Checking for installed MCP servers for project: %s (server name: %s)", project.name, project_server_name + "Checking for installed MCP servers for project: %s (SSE URL: %s)", project.name, project_sse_url ) # Check configurations for different clients @@ -542,13 +691,13 @@ async def check_installed_mcp_servers( 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"]: - await logger.adebug("Found Cursor config for project server: %s", project_server_name) + if config_contains_sse_url(cursor_config, project_sse_url): + await logger.adebug("Found Cursor config with matching SSE URL: %s", project_sse_url) results.append("cursor") else: await logger.adebug( - "Cursor config exists but no entry for server: %s (available servers: %s)", - project_server_name, + "Cursor config exists but no server with SSE URL: %s (available servers: %s)", + project_sse_url, list(cursor_config.get("mcpServers", {}).keys()), ) except json.JSONDecodeError: @@ -563,13 +712,13 @@ async def check_installed_mcp_servers( try: with windsurf_config_path.open("r") as f: windsurf_config = json.load(f) - if "mcpServers" in windsurf_config and project_server_name in windsurf_config["mcpServers"]: - await logger.adebug("Found Windsurf config for project server: %s", project_server_name) + if config_contains_sse_url(windsurf_config, project_sse_url): + await logger.adebug("Found Windsurf config with matching SSE URL: %s", project_sse_url) results.append("windsurf") else: await logger.adebug( - "Windsurf config exists but no entry for server: %s (available servers: %s)", - project_server_name, + "Windsurf config exists but no server with SSE URL: %s (available servers: %s)", + project_sse_url, list(windsurf_config.get("mcpServers", {}).keys()), ) except json.JSONDecodeError: @@ -631,13 +780,13 @@ async def check_installed_mcp_servers( 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"]: - await logger.adebug("Found Claude config for project server: %s", project_server_name) + if config_contains_sse_url(claude_config, project_sse_url): + await logger.adebug("Found Claude config with matching SSE URL: %s", project_sse_url) results.append("claude") else: await logger.adebug( - "Claude config exists but no entry for server: %s (available servers: %s)", - project_server_name, + "Claude config exists but no server with SSE URL: %s (available servers: %s)", + project_sse_url, list(claude_config.get("mcpServers", {}).keys()), ) except json.JSONDecodeError: @@ -652,6 +801,143 @@ async def check_installed_mcp_servers( return results +def config_contains_sse_url(config_data: dict, sse_url: str) -> bool: + """Check if any MCP server in the config uses the specified SSE URL.""" + mcp_servers = config_data.get("mcpServers", {}) + for server_name, server_config in mcp_servers.items(): + args = server_config.get("args", []) + # The SSE URL is typically the last argument in mcp-proxy configurations + if args and args[-1] == sse_url: + logger.debug("Found matching SSE URL in server: %s", server_name) + return True + return False + + +async def get_project_sse_url(project_id: UUID) -> str: + """Generate the SSE URL for a project, including WSL handling.""" + # 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("/") + project_sse_url = f"{base_url}/api/v1/mcp/project/{project_id}/sse" + + # Handle WSL case - must match the logic in install function + os_type = platform.system() + is_wsl = os_type == "Linux" and "microsoft" in platform.uname().release.lower() + + if is_wsl and host in {"localhost", "127.0.0.1"}: + try: + 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 + project_sse_url = project_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)) + + return project_sse_url + + +async def get_config_path(client: str) -> Path: + """Get the configuration file path for a given client and operating system.""" + os_type = platform.system() + is_wsl = os_type == "Linux" and "microsoft" in platform.uname().release.lower() + + if client.lower() == "cursor": + return Path.home() / ".cursor" / "mcp.json" + if client.lower() == "windsurf": + return Path.home() / ".codeium" / "windsurf" / "mcp_config.json" + if client.lower() == "claude": + if os_type == "Darwin": # macOS + return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" + if os_type == "Windows" or is_wsl: # Windows or WSL (Claude runs on Windows host) + if is_wsl: + # In WSL, we need to access the Windows APPDATA directory + try: + # First try to get the Windows username + proc = await create_subprocess_exec( + "/mnt/c/Windows/System32/cmd.exe", + "/c", + "echo %USERNAME%", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode == 0 and stdout.strip(): + windows_username = stdout.decode().strip() + return Path( + f"/mnt/c/Users/{windows_username}/AppData/Roaming/Claude/claude_desktop_config.json" + ) + + # Fallback: try to find the Windows user directory + users_dir = Path("/mnt/c/Users") + if users_dir.exists(): + # Get the first non-system user directory + user_dirs = [ + d + for d in users_dir.iterdir() + if d.is_dir() and not d.name.startswith(("Default", "Public", "All Users")) + ] + if user_dirs: + return user_dirs[0] / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json" + + if not Path("/mnt/c").exists(): + msg = "Windows C: drive not mounted at /mnt/c in WSL" + raise ValueError(msg) + + msg = "Could not find valid Windows user directory in WSL" + raise ValueError(msg) + except (OSError, CalledProcessError) as e: + logger.warning("Failed to determine Windows user path in WSL: %s", str(e)) + msg = f"Could not determine Windows Claude config path in WSL: {e!s}" + raise ValueError(msg) from e + # Regular Windows + return Path(os.environ["APPDATA"]) / "Claude" / "claude_desktop_config.json" + + msg = "Unsupported operating system for Claude configuration" + raise ValueError(msg) + + msg = "Unsupported client" + raise ValueError(msg) + + +def remove_server_by_sse_url(config_data: dict, sse_url: str) -> tuple[dict, list[str]]: + """Remove any MCP servers that use the specified SSE URL from config data. + + Returns: + tuple: (updated_config, list_of_removed_server_names) + """ + if "mcpServers" not in config_data: + return config_data, [] + + removed_servers: list[str] = [] + servers_to_remove: list[str] = [] + + # Find servers to remove + for server_name, server_config in config_data["mcpServers"].items(): + args = server_config.get("args", []) + if args and args[-1] == sse_url: + servers_to_remove.append(server_name) + + # Remove the servers + for server_name in servers_to_remove: + del config_data["mcpServers"][server_name] + removed_servers.append(server_name) + logger.debug("Removed existing server with matching SSE URL: %s", server_name) + + return config_data, removed_servers + + # Project-specific MCP server instance for handling project-specific tools class ProjectMCPServer: def __init__(self, project_id: UUID): diff --git a/src/backend/base/langflow/api/v1/schemas.py b/src/backend/base/langflow/api/v1/schemas.py index 34e7eb01b..6fcb8fd11 100644 --- a/src/backend/base/langflow/api/v1/schemas.py +++ b/src/backend/base/langflow/api/v1/schemas.py @@ -8,7 +8,6 @@ from pydantic import ( BaseModel, ConfigDict, Field, - SecretStr, field_serializer, field_validator, model_serializer, @@ -445,7 +444,6 @@ class AuthSettings(BaseModel): """Model representing authentication settings for MCP.""" auth_type: Literal["none", "apikey", "oauth"] = "none" - api_key: SecretStr | None = None oauth_host: str | None = None oauth_port: str | None = None oauth_server_url: str | None = None diff --git a/src/backend/tests/unit/api/v1/test_mcp_projects.py b/src/backend/tests/unit/api/v1/test_mcp_projects.py index 8e44ad4bb..b30527c02 100644 --- a/src/backend/tests/unit/api/v1/test_mcp_projects.py +++ b/src/backend/tests/unit/api/v1/test_mcp_projects.py @@ -138,34 +138,24 @@ async def other_test_project(other_test_user): async def test_handle_project_messages_success( - client: AsyncClient, mock_project, mock_sse_transport, logged_in_headers + client: AsyncClient, user_test_project, mock_sse_transport, logged_in_headers ): """Test successful handling of project messages.""" - with patch("langflow.api.v1.mcp_projects.session_scope") as mock_db: - mock_session = AsyncMock() - mock_db.return_value.__aenter__.return_value = mock_session - mock_session.exec.return_value.first.return_value = mock_project - - response = await client.post( - f"api/v1/mcp/project/{mock_project.id}", - headers=logged_in_headers, - json={"type": "test", "content": "message"}, - ) - assert response.status_code == status.HTTP_200_OK - mock_sse_transport.handle_post_message.assert_called_once() + response = await client.post( + f"api/v1/mcp/project/{user_test_project.id}", + headers=logged_in_headers, + json={"type": "test", "content": "message"}, + ) + assert response.status_code == status.HTTP_200_OK + mock_sse_transport.handle_post_message.assert_called_once() -async def test_update_project_mcp_settings_invalid_json(client: AsyncClient, mock_project, logged_in_headers): +async def test_update_project_mcp_settings_invalid_json(client: AsyncClient, user_test_project, logged_in_headers): """Test updating MCP settings with invalid JSON.""" - with patch("langflow.api.v1.mcp_projects.session_scope") as mock_db: - mock_session = AsyncMock() - mock_db.return_value.__aenter__.return_value = mock_session - mock_session.exec.return_value.first.return_value = mock_project - - response = await client.patch( - f"api/v1/mcp/project/{mock_project.id}", headers=logged_in_headers, json="invalid" - ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + response = await client.patch( + f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json="invalid" + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.fixture diff --git a/src/frontend/src/alerts/success/index.tsx b/src/frontend/src/alerts/success/index.tsx index 2b6a88dee..8c2707ba7 100644 --- a/src/frontend/src/alerts/success/index.tsx +++ b/src/frontend/src/alerts/success/index.tsx @@ -45,7 +45,7 @@ export default function SuccessAlert({ />
-

{title}

+

{title}

diff --git a/src/frontend/src/modals/authModal/index.tsx b/src/frontend/src/modals/authModal/index.tsx index bd067554f..5446a2c55 100644 --- a/src/frontend/src/modals/authModal/index.tsx +++ b/src/frontend/src/modals/authModal/index.tsx @@ -4,8 +4,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Separator } from "@/components/ui/separator"; +import { CustomLink } from "@/customization/components/custom-link"; import type { AuthSettingsType } from "@/types/mcp"; import { AUTH_METHODS_ARRAY } from "@/utils/mcpUtils"; +import { toSpaceCase } from "@/utils/stringManipulation"; import BaseModal from "../baseModal"; interface AuthModalProps { @@ -13,14 +15,22 @@ interface AuthModalProps { setOpen: (open: boolean) => void; authSettings?: AuthSettingsType; onSave: (authSettings: AuthSettingsType) => void; + installedClients?: string[]; + autoInstall?: boolean; } -const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => { +const AuthModal = ({ + open, + setOpen, + authSettings, + autoInstall, + onSave, + installedClients, +}: AuthModalProps) => { const [authType, setAuthType] = useState( authSettings?.auth_type || "none", ); const [authFields, setAuthFields] = useState<{ - apiKey?: string; oauthHost?: string; oauthPort?: string; oauthServerUrl?: string; @@ -32,7 +42,6 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => { oauthMcpScope?: string; oauthProviderScope?: string; }>({ - apiKey: authSettings?.api_key || "", oauthHost: authSettings?.oauth_host || "", oauthPort: authSettings?.oauth_port || "", oauthServerUrl: authSettings?.oauth_server_url || "", @@ -50,7 +59,6 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => { if (authSettings) { setAuthType(authSettings.auth_type || "none"); setAuthFields({ - apiKey: authSettings.api_key || "", oauthHost: authSettings.oauth_host || "", oauthPort: authSettings.oauth_port || "", oauthServerUrl: authSettings.oauth_server_url || "", @@ -80,7 +88,6 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => { const handleSave = () => { const authSettingsToSave: AuthSettingsType = { auth_type: authType, - ...(authType === "apikey" && { api_key: authFields.apiKey }), ...(authType === "oauth" && { oauth_host: authFields.oauthHost, oauth_port: authFields.oauthPort, @@ -116,7 +123,7 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => {
{/* Left column - Radio buttons */} -
+
Auth type @@ -151,20 +158,28 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => { {authType !== "none" && (
{authType === "apikey" && ( -
- - - handleAuthFieldChange("apiKey", e.target.value) - } - /> -
+ +

+ Create a key in{" "} + + Settings + {" "} + and include it in the{" "} + install JSON. Or, + create a key automatically from the{" "} + JSON tab. +

+ {autoInstall && ( +

+ Auto Install{" "} + creates and injects a key into the selected client profile + on this machine. +

+ )} +
)} {authType === "oauth" && ( @@ -365,7 +380,21 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => { onClick: handleSave, }} className="p-4 border-t" - /> + > +
+ + + {installedClients && installedClients.length > 0 + ? `Changing auth type requires reinstalling this server in ${installedClients + .map((client) => toSpaceCase(client)) + .join(", ")} and any other clients where it's used.` + : "Changing auth type requires reinstalling this server in all clients where it's used."} + +
+ ); }; diff --git a/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx b/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx index 30573f89d..c1640aca7 100644 --- a/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx +++ b/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx @@ -27,12 +27,42 @@ import { AUTH_METHODS } from "@/utils/mcpUtils"; import { parseString } from "@/utils/stringManipulation"; import { cn, getOS } from "@/utils/utils"; +interface MemoizedApiKeyButtonProps { + apiKey: string; + isGeneratingApiKey: boolean; + generateApiKey: () => void; +} + +const MemoizedApiKeyButton = memo( + ({ + apiKey, + isGeneratingApiKey, + generateApiKey, + }: MemoizedApiKeyButtonProps) => ( + + ), +); +MemoizedApiKeyButton.displayName = "MemoizedApiKeyButton"; + // Define interface for MemoizedCodeTag props interface MemoizedCodeTagProps { children: ReactNode; isCopied: boolean; copyToClipboard: () => void; - isAutoLogin: boolean | null; + isAuthApiKey: boolean | null; apiKey: string; isGeneratingApiKey: boolean; generateApiKey: () => void; @@ -44,30 +74,19 @@ const MemoizedCodeTag = memo( children, isCopied, copyToClipboard, - isAutoLogin, + isAuthApiKey, apiKey, isGeneratingApiKey, generateApiKey, }: MemoizedCodeTagProps) => (
- {!isAutoLogin && ( - + {isAuthApiKey && ( + )}
- - + + {installedMCP?.includes(installer.name) && ( + )} - /> +
))}
@@ -576,7 +608,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { open={authModalOpen} setOpen={setAuthModalOpen} authSettings={currentAuthSettings} + autoInstall={isLocalConnection} onSave={handleAuthSave} + installedClients={installedMCP ?? []} /> )}
diff --git a/src/frontend/src/types/mcp/index.ts b/src/frontend/src/types/mcp/index.ts index aed741c45..5c8edc565 100644 --- a/src/frontend/src/types/mcp/index.ts +++ b/src/frontend/src/types/mcp/index.ts @@ -1,10 +1,5 @@ export type AuthSettingsType = { auth_type: string; - api_key?: string; - username?: string; - password?: string; - bearer_token?: string; - iam_endpoint?: string; oauth_host?: string; oauth_port?: string; oauth_server_url?: string; diff --git a/src/frontend/src/utils/stringManipulation.ts b/src/frontend/src/utils/stringManipulation.ts index 48148221b..1308f3aa6 100644 --- a/src/frontend/src/utils/stringManipulation.ts +++ b/src/frontend/src/utils/stringManipulation.ts @@ -35,7 +35,7 @@ function toUpperCase(str: string): string { return str?.toUpperCase(); } -function toSpaceCase(str: string): string { +export function toSpaceCase(str: string): string { return str .trim() .replace(/[_\s-]+/g, " ")