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>
This commit is contained in:
parent
bc82d99907
commit
795c62ae22
8 changed files with 468 additions and 136 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export default function SuccessAlert({
|
|||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="success-alert-message line-clamp-2">{title}</p>
|
||||
<p className="success-alert-message line-clamp-3">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string>(
|
||||
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) => {
|
|||
</div>
|
||||
<div className="flex h-full p-0 border-t rounded-none">
|
||||
{/* Left column - Radio buttons */}
|
||||
<div className="flex flex-col p-4 gap-2 flex-1 items-start min-h-[400px]">
|
||||
<div className="flex flex-col p-4 gap-2 flex-1 items-start min-h-[250px] transition-all">
|
||||
<span className="text-mmd font-medium text-muted-foreground">
|
||||
Auth type
|
||||
</span>
|
||||
|
|
@ -151,20 +158,28 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => {
|
|||
{authType !== "none" && (
|
||||
<div className="w-[70%] flex flex-col overflow-y-auto h-fit max-h-[400px] p-4">
|
||||
{authType === "apikey" && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Label htmlFor="api-key" className="!text-mmd font-medium">
|
||||
API Key Value
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
placeholder="Enter API Key"
|
||||
value={authFields.apiKey || ""}
|
||||
onChange={(e) =>
|
||||
handleAuthFieldChange("apiKey", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex flex-col items-start gap-1 text-mmd text-muted-foreground">
|
||||
<p>
|
||||
Create a key in{" "}
|
||||
<CustomLink
|
||||
className="text-accent-pink-foreground underline inline-block"
|
||||
to="/settings/api-keys"
|
||||
>
|
||||
Settings
|
||||
</CustomLink>{" "}
|
||||
and include it in the{" "}
|
||||
<span className="font-semibold">install JSON</span>. Or,
|
||||
create a key automatically from the{" "}
|
||||
<span className="font-semibold">JSON tab</span>.
|
||||
</p>
|
||||
{autoInstall && (
|
||||
<p>
|
||||
<span className="font-semibold">Auto Install</span>{" "}
|
||||
creates and injects a key into the selected client profile
|
||||
on this machine.
|
||||
</p>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{authType === "oauth" && (
|
||||
|
|
@ -365,7 +380,21 @@ const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => {
|
|||
onClick: handleSave,
|
||||
}}
|
||||
className="p-4 border-t"
|
||||
/>
|
||||
>
|
||||
<div className="flex items-center text-accent-amber-foreground gap-2 text-sm pr-2">
|
||||
<ForwardedIconComponent
|
||||
name="AlertTriangle"
|
||||
className="h-4 w-4 shrink-0 text-accent-amber-foreground"
|
||||
/>
|
||||
<span className="text-mmd text-muted-foreground">
|
||||
{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."}
|
||||
</span>
|
||||
</div>
|
||||
</BaseModal.Footer>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<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>
|
||||
),
|
||||
);
|
||||
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) => (
|
||||
<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>
|
||||
{isAuthApiKey && (
|
||||
<MemoizedApiKeyButton
|
||||
apiKey={apiKey}
|
||||
isGeneratingApiKey={isGeneratingApiKey}
|
||||
generateApiKey={generateApiKey}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
unstyled
|
||||
|
|
@ -144,6 +163,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
// Extract tools and auth_settings from the response
|
||||
const flowsMCP = mcpProjectData?.tools || [];
|
||||
const currentAuthSettings = mcpProjectData?.auth_settings;
|
||||
|
||||
const { mutate: patchInstallMCP } = usePatchInstallMCP({
|
||||
project_id: projectId,
|
||||
});
|
||||
|
|
@ -156,6 +176,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
);
|
||||
|
||||
const isAutoLogin = useAuthStore((state) => state.autoLogin);
|
||||
const isAuthApiKey = ENABLE_MCP_COMPOSER
|
||||
? currentAuthSettings?.auth_type === "apikey"
|
||||
: !isAutoLogin;
|
||||
|
||||
// Check if the current connection is local
|
||||
const isLocalConnection = useCustomIsLocalConnection();
|
||||
|
|
@ -244,7 +267,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
return `
|
||||
"--headers",
|
||||
"x-api-key",
|
||||
"${currentAuthSettings.api_key || "YOUR_API_KEY"}",`;
|
||||
"${apiKey || "YOUR_API_KEY"}",`;
|
||||
}
|
||||
|
||||
return "";
|
||||
|
|
@ -456,7 +479,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
<MemoizedCodeTag
|
||||
isCopied={isCopied}
|
||||
copyToClipboard={copyToClipboard}
|
||||
isAutoLogin={isAutoLogin}
|
||||
isAuthApiKey={isAuthApiKey}
|
||||
apiKey={apiKey}
|
||||
isGeneratingApiKey={isGeneratingApiKey}
|
||||
generateApiKey={generateApiKey}
|
||||
|
|
@ -505,11 +528,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
<Button
|
||||
key={installer.name}
|
||||
variant="ghost"
|
||||
className="flex items-center justify-between disabled:text-foreground disabled:opacity-50"
|
||||
className="group flex items-center justify-between disabled:text-foreground disabled:opacity-50"
|
||||
disabled={
|
||||
installedMCP?.includes(installer.name) ||
|
||||
loadingMCP.includes(installer.name) ||
|
||||
!isLocalConnection
|
||||
loadingMCP.includes(installer.name) || !isLocalConnection
|
||||
}
|
||||
onClick={() => {
|
||||
setLoadingMCP([...loadingMCP, installer.name]);
|
||||
|
|
@ -551,20 +572,31 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
/>
|
||||
{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",
|
||||
<div className="relative h-4 w-4">
|
||||
<ForwardedIconComponent
|
||||
name={
|
||||
installedMCP?.includes(installer.name)
|
||||
? "Check"
|
||||
: loadingMCP.includes(installer.name)
|
||||
? "Loader2"
|
||||
: "Plus"
|
||||
}
|
||||
className={cn(
|
||||
"h-4 w-4 absolute top-0 left-0 opacity-100",
|
||||
loadingMCP.includes(installer.name) && "animate-spin",
|
||||
installedMCP?.includes(installer.name) &&
|
||||
"group-hover:opacity-0",
|
||||
)}
|
||||
/>
|
||||
{installedMCP?.includes(installer.name) && (
|
||||
<ForwardedIconComponent
|
||||
name={"RefreshCw"}
|
||||
className={cn(
|
||||
"h-4 w-4 absolute top-0 left-0 opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -576,7 +608,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
open={authModalOpen}
|
||||
setOpen={setAuthModalOpen}
|
||||
authSettings={currentAuthSettings}
|
||||
autoInstall={isLocalConnection}
|
||||
onSave={handleAuthSave}
|
||||
installedClients={installedMCP ?? []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, " ")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue