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:
Lucas Oliveira 2025-08-25 14:13:07 -03:00 committed by GitHub
commit 795c62ae22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 468 additions and 136 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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