diff --git a/src/backend/base/langflow/alembic/versions/3162e83e485f_add_auth_settings_to_folder_and_merge.py b/src/backend/base/langflow/alembic/versions/3162e83e485f_add_auth_settings_to_folder_and_merge.py new file mode 100644 index 000000000000..7e1bfd538594 --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/3162e83e485f_add_auth_settings_to_folder_and_merge.py @@ -0,0 +1,58 @@ +"""Add auth_settings column to folder table and merge migration branches. + +Revision ID: 3162e83e485f +Revises: 0ae3a2674f32, d9a6ea21edcd +Create Date: 2025-01-16 13:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3162e83e485f" +down_revision: str | Sequence[str] | None = ("0ae3a2674f32", "d9a6ea21edcd") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add auth_settings column to folder table and merge migration branches.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Check if folder table exists + table_names = inspector.get_table_names() + if "folder" not in table_names: + # If folder table doesn't exist, skip this migration + return + + # Get current column names in folder table + column_names = [column["name"] for column in inspector.get_columns("folder")] + + # Add auth_settings column to folder table if it doesn't exist + with op.batch_alter_table("folder", schema=None) as batch_op: + if "auth_settings" not in column_names: + batch_op.add_column(sa.Column("auth_settings", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + """Remove auth_settings column from folder table.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Check if folder table exists + table_names = inspector.get_table_names() + if "folder" not in table_names: + # If folder table doesn't exist, skip this migration + return + + # Get current column names in folder table + column_names = [column["name"] for column in inspector.get_columns("folder")] + + # Remove auth_settings column from folder table if it exists + with op.batch_alter_table("folder", schema=None) as batch_op: + if "auth_settings" in column_names: + batch_op.drop_column("auth_settings") diff --git a/src/backend/base/langflow/api/v1/mcp_projects.py b/src/backend/base/langflow/api/v1/mcp_projects.py index 0c313f38f411..832f76c0f9e8 100644 --- a/src/backend/base/langflow/api/v1/mcp_projects.py +++ b/src/backend/base/langflow/api/v1/mcp_projects.py @@ -29,11 +29,17 @@ handle_mcp_errors, handle_read_resource, ) -from langflow.api.v1.schemas import MCPInstallRequest, MCPSettings +from langflow.api.v1.schemas import ( + 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.services.database.models import Flow, Folder from langflow.services.deps import get_settings_service, session_scope +from langflow.services.settings.feature_flags import FEATURE_FLAGS logger = logging.getLogger(__name__) @@ -60,7 +66,7 @@ async def list_project_tools( current_user: CurrentActiveMCPUser, *, mcp_enabled: bool = True, -) -> list[MCPSettings]: +) -> MCPProjectResponse: """List all tools in a project that are enabled for MCP.""" tools: list[MCPSettings] = [] try: @@ -114,12 +120,19 @@ async def list_project_tools( logger.warning(msg) continue + # 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: msg = f"Error listing project tools: {e!s}" logger.exception(msg) raise HTTPException(status_code=500, detail=str(e)) from e - return tools + return MCPProjectResponse(tools=tools, auth_settings=auth_settings) @router.head("/{project_id}/sse", response_class=HTMLResponse, include_in_schema=False) @@ -218,10 +231,10 @@ async def handle_project_messages_with_slash(project_id: UUID, request: Request, @router.patch("/{project_id}", status_code=200) async def update_project_mcp_settings( project_id: UUID, - settings: list[MCPSettings], + request: MCPProjectUpdateRequest, current_user: CurrentActiveMCPUser, ): - """Update the MCP settings of all flows in a project.""" + """Update the MCP settings of all flows in a project and project-level auth settings.""" try: async with session_scope() as session: # Fetch the project first to verify it exists and belongs to the current user @@ -236,9 +249,16 @@ async def update_project_mcp_settings( if not project: raise HTTPException(status_code=404, detail="Project not found") + # Update project-level auth settings + if request.auth_settings: + project.auth_settings = request.auth_settings.model_dump(mode="json") + else: + project.auth_settings = None + session.add(project) + # Query flows in the project flows = (await session.exec(select(Flow).where(Flow.folder_id == project_id))).all() - flows_to_update = {x.id: x for x in settings} + flows_to_update = {x.id: x for x in request.settings} updated_flows = [] for flow in flows: @@ -256,7 +276,7 @@ async def update_project_mcp_settings( await session.commit() - return {"message": f"Updated MCP settings for {len(updated_flows)} flows"} + return {"message": f"Updated MCP settings for {len(updated_flows)} flows and project auth settings"} except Exception as e: msg = f"Error updating project MCP settings: {e!s}" @@ -348,7 +368,8 @@ async def install_mcp_config( # Determine command and args based on operating system os_type = platform.system() command = "uvx" - args = ["mcp-proxy", sse_url] + mcp_tool = "mcp-composer" if FEATURE_FLAGS.mcp_composer else "mcp-proxy" + args = [mcp_tool, sse_url] # Check if running on WSL (will appear as Linux but with Microsoft in release info) is_wsl = os_type == "Linux" and "microsoft" in platform.uname().release.lower() @@ -381,7 +402,7 @@ async def install_mcp_config( if os_type == "Windows": command = "cmd" - args = ["/c", "uvx", "mcp-proxy", sse_url] + args = ["/c", "uvx", mcp_tool, sse_url] logger.debug("Windows detected, using cmd command") name = project.name diff --git a/src/backend/base/langflow/api/v1/schemas.py b/src/backend/base/langflow/api/v1/schemas.py index 4229dfd69036..bd55ddf82d9e 100644 --- a/src/backend/base/langflow/api/v1/schemas.py +++ b/src/backend/base/langflow/api/v1/schemas.py @@ -8,6 +8,7 @@ BaseModel, ConfigDict, Field, + SecretStr, field_serializer, field_validator, model_serializer, @@ -440,6 +441,27 @@ class CancelFlowResponse(BaseModel): message: str +class AuthSettings(BaseModel): + """Model representing authentication settings for MCP.""" + + auth_type: Literal["none", "apikey", "basic", "bearer", "iam", "oauth"] = "none" + api_key: SecretStr | None = None + username: str | None = None + password: SecretStr | None = None + bearer_token: SecretStr | None = None + iam_endpoint: str | None = None + oauth_host: str | None = None + oauth_port: str | None = None + oauth_server_url: str | None = None + oauth_callback_path: str | None = None + oauth_client_id: str | None = None + oauth_client_secret: str | None = None + oauth_auth_url: str | None = None + oauth_token_url: str | None = None + oauth_mcp_scope: str | None = None + oauth_provider_scope: str | None = None + + class MCPSettings(BaseModel): """Model representing MCP settings for a flow.""" @@ -451,5 +473,19 @@ class MCPSettings(BaseModel): description: str | None = None +class MCPProjectUpdateRequest(BaseModel): + """Request model for updating MCP project settings including auth.""" + + settings: list[MCPSettings] + auth_settings: AuthSettings | None = None + + +class MCPProjectResponse(BaseModel): + """Response model for MCP project tools with auth settings.""" + + tools: list[MCPSettings] + auth_settings: AuthSettings | None = None + + class MCPInstallRequest(BaseModel): client: str diff --git a/src/backend/base/langflow/services/database/models/folder/model.py b/src/backend/base/langflow/services/database/models/folder/model.py index 82a2e6d56528..93949c48417e 100644 --- a/src/backend/base/langflow/services/database/models/folder/model.py +++ b/src/backend/base/langflow/services/database/models/folder/model.py @@ -2,7 +2,7 @@ from uuid import UUID, uuid4 from sqlalchemy import Text, UniqueConstraint -from sqlmodel import Column, Field, Relationship, SQLModel +from sqlmodel import JSON, Column, Field, Relationship, SQLModel from langflow.services.database.models.flow.model import Flow, FlowRead from langflow.services.database.models.user.model import User @@ -11,6 +11,11 @@ class FolderBase(SQLModel): name: str = Field(index=True) description: str | None = Field(default=None, sa_column=Column(Text)) + auth_settings: dict | None = Field( + default=None, + sa_column=Column(JSON, nullable=True), + description="Authentication settings for the folder/project", + ) class Folder(FolderBase, table=True): # type: ignore[call-arg] @@ -53,3 +58,4 @@ class FolderUpdate(SQLModel): parent_id: UUID | None = None components: list[UUID] = Field(default_factory=list) flows: list[UUID] = Field(default_factory=list) + auth_settings: dict | None = None diff --git a/src/backend/base/langflow/services/settings/feature_flags.py b/src/backend/base/langflow/services/settings/feature_flags.py index a234710d41a0..d543df5a17f8 100644 --- a/src/backend/base/langflow/services/settings/feature_flags.py +++ b/src/backend/base/langflow/services/settings/feature_flags.py @@ -3,6 +3,7 @@ class FeatureFlags(BaseSettings): mvp_components: bool = False + mcp_composer: bool = True class Config: env_prefix = "LANGFLOW_FEATURE_" 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 5de5c6321cd2..8e44ad4bbfba 100644 --- a/src/backend/tests/unit/api/v1/test_mcp_projects.py +++ b/src/backend/tests/unit/api/v1/test_mcp_projects.py @@ -206,20 +206,30 @@ async def test_update_project_mcp_settings_success( ): """Test successful update of MCP settings using real database.""" # Create settings for updating the flow - settings = [ - { - "id": str(test_flow_for_update.id), - "action_name": "updated_action", - "action_description": "Updated description", - "mcp_enabled": False, - "name": test_flow_for_update.name, - "description": test_flow_for_update.description, - } - ] + json_payload = { + "settings": [ + { + "id": str(test_flow_for_update.id), + "action_name": "updated_action", + "action_description": "Updated description", + "mcp_enabled": False, + "name": test_flow_for_update.name, + "description": test_flow_for_update.description, + } + ], + "auth_settings": { + "auth_type": "none", + "api_key": None, + "iam_endpoint": None, + "username": None, + "password": None, + "bearer_token": None, + }, + } # Make the real PATCH request response = await client.patch( - f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=settings + f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=json_payload ) # Assert response @@ -268,11 +278,21 @@ async def test_update_project_mcp_settings_empty_settings(client: AsyncClient, u # Use real database objects instead of mocks to avoid the coroutine issue # Empty settings list - settings: list = [] + json_payload = { + "settings": [], + "auth_settings": { + "auth_type": "none", + "api_key": None, + "iam_endpoint": None, + "username": None, + "password": None, + "bearer_token": None, + }, + } # Make the request to the actual endpoint response = await client.patch( - f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=settings + f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=json_payload ) # Verify response - the real endpoint should handle empty settings correctly @@ -385,20 +405,30 @@ async def test_user_can_update_own_flow_mcp_settings( ): """Test that a user can update MCP settings for their own flows using real database.""" # User attempts to update their own flow settings - updated_settings = [ - { - "id": str(user_test_flow.id), - "action_name": "updated_user_action", - "action_description": "Updated user action description", - "mcp_enabled": False, - "name": "User Test Flow", - "description": "This flow belongs to the active user", - } - ] + json_payload = { + "settings": [ + { + "id": str(user_test_flow.id), + "action_name": "updated_user_action", + "action_description": "Updated user action description", + "mcp_enabled": False, + "name": "User Test Flow", + "description": "This flow belongs to the active user", + } + ], + "auth_settings": { + "auth_type": "none", + "api_key": None, + "iam_endpoint": None, + "username": None, + "password": None, + "bearer_token": None, + }, + } # Make the PATCH request to update settings response = await client.patch( - f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=updated_settings + f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=json_payload ) # Should succeed as the user owns this project and flow diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 9a424fe19e40..c3717f816b51 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.2.1", @@ -5048,6 +5049,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz", + "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 84f4355562b5..985b6ed80517 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.2.1", diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/ToolsComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/ToolsComponent/index.tsx index 5fcbe726f916..bcd3fc343800 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/ToolsComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/ToolsComponent/index.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; +import ShadTooltip from "@/components/common/shadTooltipComponent"; import { ICON_STROKE_WIDTH } from "@/constants/constants"; +import { ENABLE_MCP_COMPOSER } from "@/customization/feature-flags"; import ToolsModal from "@/modals/toolsModal"; import { cn, testIdCase } from "@/utils/utils"; import { ForwardedIconComponent } from "../../../../common/genericIconComponent"; @@ -59,28 +61,47 @@ export default function ToolsComponent({ icon={icon} />
- {(visibleActions.length > 0 || isAction) && ( - - )} +
+
+ +
+ Flows/Tools +
+
+
+ {(visibleActions.length > 0 || isAction) && ( + + )} +
{!value ? (
{[...Array(4)].map((_, index) => ( diff --git a/src/frontend/src/components/ui/radio-group.tsx b/src/frontend/src/components/ui/radio-group.tsx new file mode 100644 index 000000000000..3d1156dda801 --- /dev/null +++ b/src/frontend/src/components/ui/radio-group.tsx @@ -0,0 +1,40 @@ +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { CircleIcon } from "lucide-react"; +import * as React from "react"; +import { cn } from "@/utils/utils"; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} +export { RadioGroup, RadioGroupItem }; diff --git a/src/frontend/src/controllers/API/queries/mcp/use-get-flows-mcp.ts b/src/frontend/src/controllers/API/queries/mcp/use-get-flows-mcp.ts index 939e33e5f3fd..750686bb415e 100644 --- a/src/frontend/src/controllers/API/queries/mcp/use-get-flows-mcp.ts +++ b/src/frontend/src/controllers/API/queries/mcp/use-get-flows-mcp.ts @@ -1,5 +1,5 @@ import type { useQueryFunctionType } from "@/types/api"; -import type { MCPSettingsType } from "@/types/mcp"; +import type { MCPProjectResponseType } from "@/types/mcp"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; import { UseRequestProcessor } from "../../services/request-processor"; @@ -8,7 +8,7 @@ interface IGetFlowsMCP { projectId: string; } -type getFlowsMCPResponse = Array; +type getFlowsMCPResponse = MCPProjectResponseType; export const useGetFlowsMCP: useQueryFunctionType< IGetFlowsMCP, @@ -24,7 +24,7 @@ export const useGetFlowsMCP: useQueryFunctionType< return data; } catch (error) { console.error(error); - return []; + return { tools: [], auth_settings: undefined }; } }; diff --git a/src/frontend/src/controllers/API/queries/mcp/use-patch-flows-mcp.ts b/src/frontend/src/controllers/API/queries/mcp/use-patch-flows-mcp.ts index a4457f7e142e..ed9c2df756cf 100644 --- a/src/frontend/src/controllers/API/queries/mcp/use-patch-flows-mcp.ts +++ b/src/frontend/src/controllers/API/queries/mcp/use-patch-flows-mcp.ts @@ -1,6 +1,6 @@ import type { UseMutationResult } from "@tanstack/react-query"; import type { useMutationFunctionType } from "@/types/api"; -import type { MCPSettingsType } from "@/types/mcp"; +import type { AuthSettingsType, MCPSettingsType } from "@/types/mcp"; import { api } from "../../api"; import { getURL } from "../../helpers/constants"; import { UseRequestProcessor } from "../../services/request-processor"; @@ -9,21 +9,26 @@ interface PatchFlowMCPParams { project_id: string; } +interface PatchFlowMCPRequest { + settings: MCPSettingsType[]; + auth_settings?: AuthSettingsType; +} + interface PatchFlowMCPResponse { message: string; } export const usePatchFlowsMCP: useMutationFunctionType< PatchFlowMCPParams, - MCPSettingsType[], + PatchFlowMCPRequest, PatchFlowMCPResponse > = (params, options?) => { const { mutate, queryClient } = UseRequestProcessor(); - async function patchFlowMCP(flowMCP: MCPSettingsType[]): Promise { + async function patchFlowMCP(requestData: PatchFlowMCPRequest): Promise { const res = await api.patch( `${getURL("MCP")}/${params.project_id}`, - flowMCP, + requestData, ); return res.data.message; } @@ -31,7 +36,7 @@ export const usePatchFlowsMCP: useMutationFunctionType< const mutation: UseMutationResult< PatchFlowMCPResponse, any, - MCPSettingsType[] + PatchFlowMCPRequest > = mutate(["usePatchFlowsMCP"], patchFlowMCP, { onSettled: () => { queryClient.refetchQueries({ queryKey: ["useGetFlowsMCP"] }); diff --git a/src/frontend/src/customization/feature-flags.ts b/src/frontend/src/customization/feature-flags.ts index 725c8baee491..79c18b31b51d 100644 --- a/src/frontend/src/customization/feature-flags.ts +++ b/src/frontend/src/customization/feature-flags.ts @@ -15,3 +15,5 @@ export const ENABLE_VOICE_ASSISTANT = true; export const ENABLE_IMAGE_ON_PLAYGROUND = false; export const ENABLE_MCP = true; export const ENABLE_MCP_NOTICE = false; +export const ENABLE_MCP_COMPOSER = + process.env.LANGFLOW_FEATURE_MCP_COMPOSER === "true"; diff --git a/src/frontend/src/modals/authModal/index.tsx b/src/frontend/src/modals/authModal/index.tsx new file mode 100644 index 000000000000..45ce08a46bc9 --- /dev/null +++ b/src/frontend/src/modals/authModal/index.tsx @@ -0,0 +1,468 @@ +import { useEffect, useState } from "react"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +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 type { AuthSettingsType } from "@/types/mcp"; +import { AUTH_METHODS_ARRAY } from "@/utils/mcpUtils"; +import BaseModal from "../baseModal"; + +interface AuthModalProps { + open: boolean; + setOpen: (open: boolean) => void; + authSettings?: AuthSettingsType; + onSave: (authSettings: AuthSettingsType) => void; +} + +const AuthModal = ({ open, setOpen, authSettings, onSave }: AuthModalProps) => { + const [authType, setAuthType] = useState( + authSettings?.auth_type || "none", + ); + const [authFields, setAuthFields] = useState<{ + apiKey?: string; + iamEndpoint?: string; + username?: string; + password?: string; + bearerToken?: string; + oauthHost?: string; + oauthPort?: string; + oauthServerUrl?: string; + oauthCallbackPath?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthAuthUrl?: string; + oauthTokenUrl?: string; + oauthMcpScope?: string; + oauthProviderScope?: string; + }>({ + apiKey: authSettings?.api_key || "", + iamEndpoint: authSettings?.iam_endpoint || "", + username: authSettings?.username || "", + password: authSettings?.password || "", + bearerToken: authSettings?.bearer_token || "", + oauthHost: authSettings?.oauth_host || "", + oauthPort: authSettings?.oauth_port || "", + oauthServerUrl: authSettings?.oauth_server_url || "", + oauthCallbackPath: authSettings?.oauth_callback_path || "", + oauthClientId: authSettings?.oauth_client_id || "", + oauthClientSecret: authSettings?.oauth_client_secret || "", + oauthAuthUrl: authSettings?.oauth_auth_url || "", + oauthTokenUrl: authSettings?.oauth_token_url || "", + oauthMcpScope: authSettings?.oauth_mcp_scope || "", + oauthProviderScope: authSettings?.oauth_provider_scope || "", + }); + + // Update auth state when authSettings prop changes + useEffect(() => { + if (authSettings) { + setAuthType(authSettings.auth_type || "none"); + setAuthFields({ + apiKey: authSettings.api_key || "", + iamEndpoint: authSettings.iam_endpoint || "", + username: authSettings.username || "", + password: authSettings.password || "", + bearerToken: authSettings.bearer_token || "", + oauthHost: authSettings.oauth_host || "", + oauthPort: authSettings.oauth_port || "", + oauthServerUrl: authSettings.oauth_server_url || "", + oauthCallbackPath: authSettings.oauth_callback_path || "", + oauthClientId: authSettings.oauth_client_id || "", + oauthClientSecret: authSettings.oauth_client_secret || "", + oauthAuthUrl: authSettings.oauth_auth_url || "", + oauthTokenUrl: authSettings.oauth_token_url || "", + oauthMcpScope: authSettings.oauth_mcp_scope || "", + oauthProviderScope: authSettings.oauth_provider_scope || "", + }); + } + }, [authSettings]); + + const handleAuthTypeChange = (value: string) => { + setAuthType(value); + setAuthFields({}); + }; + + const handleAuthFieldChange = (field: string, value: string) => { + setAuthFields((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const handleSave = () => { + const authSettingsToSave: AuthSettingsType = { + auth_type: authType, + ...(authType === "apikey" && { api_key: authFields.apiKey }), + ...(authType === "basic" && { + username: authFields.username, + password: authFields.password, + }), + ...(authType === "iam" && { + iam_endpoint: authFields.iamEndpoint, + api_key: authFields.apiKey, + }), + ...(authType === "bearer" && { bearer_token: authFields.bearerToken }), + ...(authType === "oauth" && { + oauth_host: authFields.oauthHost, + oauth_port: authFields.oauthPort, + oauth_server_url: authFields.oauthServerUrl, + oauth_callback_path: authFields.oauthCallbackPath, + oauth_client_id: authFields.oauthClientId, + oauth_client_secret: authFields.oauthClientSecret, + oauth_auth_url: authFields.oauthAuthUrl, + oauth_token_url: authFields.oauthTokenUrl, + oauth_mcp_scope: authFields.oauthMcpScope, + oauth_provider_scope: authFields.oauthProviderScope, + }), + }; + + onSave(authSettingsToSave); + setOpen(false); + }; + + return ( + + +
Authentication
+
+ +
+ {/* Left column - Radio buttons */} +
+ + {AUTH_METHODS_ARRAY.map((option) => ( +
+ + +
+ ))} +
+
+ {authType !== "none" && } + + {/* Right column - Input fields */} + {authType !== "none" && ( +
+ {authType === "apikey" && ( +
+ + + handleAuthFieldChange("apiKey", e.target.value) + } + /> +
+ )} + + {authType === "basic" && ( +
+
+ + + handleAuthFieldChange("username", e.target.value) + } + /> +
+
+ + + handleAuthFieldChange("password", e.target.value) + } + /> +
+
+ )} + + {authType === "bearer" && ( +
+ + + handleAuthFieldChange("bearerToken", e.target.value) + } + /> +
+ )} + + {authType === "iam" && ( +
+
+ + + handleAuthFieldChange("iamEndpoint", e.target.value) + } + /> +
+
+ + + handleAuthFieldChange("apiKey", e.target.value) + } + /> +
+
+ )} + + {authType === "oauth" && ( +
+
+
+ + + handleAuthFieldChange("oauthHost", e.target.value) + } + /> +
+
+ + + handleAuthFieldChange("oauthPort", e.target.value) + } + /> +
+
+
+ + + handleAuthFieldChange("oauthServerUrl", e.target.value) + } + /> +
+
+ + + handleAuthFieldChange( + "oauthCallbackPath", + e.target.value, + ) + } + /> +
+
+
+ + + handleAuthFieldChange("oauthClientId", e.target.value) + } + /> +
+
+ + + handleAuthFieldChange( + "oauthClientSecret", + e.target.value, + ) + } + /> +
+
+
+ + + handleAuthFieldChange("oauthAuthUrl", e.target.value) + } + /> +
+
+ + + handleAuthFieldChange("oauthTokenUrl", e.target.value) + } + /> +
+
+
+ + + handleAuthFieldChange("oauthMcpScope", e.target.value) + } + /> +
+
+ + + handleAuthFieldChange( + "oauthProviderScope", + e.target.value, + ) + } + /> +
+
+
+ )} +
+ )} +
+
+ +
+ ); +}; + +export default AuthModal; diff --git a/src/frontend/src/modals/toolsModal/index.tsx b/src/frontend/src/modals/toolsModal/index.tsx index d39581820316..39def4fe8567 100644 --- a/src/frontend/src/modals/toolsModal/index.tsx +++ b/src/frontend/src/modals/toolsModal/index.tsx @@ -73,18 +73,20 @@ const ToolsModal = forwardRef(
-
- - - +
+
+ + + +
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 e992a030290f..f6cc832cc8ff 100644 --- a/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx +++ b/src/frontend/src/pages/MainPage/pages/homePage/components/McpServerTab.tsx @@ -14,13 +14,16 @@ import { } from "@/controllers/API/queries/mcp"; import { useGetInstalledMCP } from "@/controllers/API/queries/mcp/use-get-installed-mcp"; import { usePatchInstallMCP } from "@/controllers/API/queries/mcp/use-patch-install-mcp"; +import { ENABLE_MCP_COMPOSER } from "@/customization/feature-flags"; import { useCustomIsLocalConnection } from "@/customization/hooks/use-custom-is-local-connection"; import useTheme from "@/customization/hooks/use-custom-theme"; import { customGetMCPUrl } from "@/customization/utils/custom-mcp-url"; +import AuthModal from "@/modals/authModal"; import useAlertStore from "@/stores/alertStore"; import useAuthStore from "@/stores/authStore"; import { useFolderStore } from "@/stores/foldersStore"; -import type { MCPSettingsType } from "@/types/mcp"; +import type { AuthSettingsType, MCPSettingsType } from "@/types/mcp"; +import { AUTH_METHODS } from "@/utils/mcpUtils"; import { parseString } from "@/utils/stringManipulation"; import { cn, getOS } from "@/utils/utils"; @@ -131,11 +134,16 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { const [isCopied, setIsCopied] = useState(false); const [apiKey, setApiKey] = useState(""); const [isGeneratingApiKey, setIsGeneratingApiKey] = useState(false); + const [authModalOpen, setAuthModalOpen] = useState(false); const setSuccessData = useAlertStore((state) => state.setSuccessData); const setErrorData = useAlertStore((state) => state.setErrorData); - const { data: flowsMCP } = useGetFlowsMCP({ projectId }); + const { data: mcpProjectData } = useGetFlowsMCP({ projectId }); const { mutate: patchFlowsMCP } = usePatchFlowsMCP({ project_id: projectId }); + + // 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,14 +164,44 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { isLocalConnection ? "Auto install" : "JSON", ); - const handleOnNewValue = (value) => { - const flowsMCPData: MCPSettingsType[] = value.value.map((flow) => ({ + const handleOnNewValue = (value: any) => { + const flowsMCPData: MCPSettingsType[] = value.value.map((flow: any) => ({ id: flow.id, action_name: flow.name, action_description: flow.description, mcp_enabled: flow.status, })); - patchFlowsMCP(flowsMCPData); + + // Prepare the request with both settings and auth_settings + // If ENABLE_MCP_COMPOSER is false, always use "none" for auth_type + const finalAuthSettings = ENABLE_MCP_COMPOSER + ? currentAuthSettings + : { auth_type: "none" }; + + const requestData = { + settings: flowsMCPData, + auth_settings: finalAuthSettings, + }; + + patchFlowsMCP(requestData); + }; + + const handleAuthSave = (authSettings: AuthSettingsType) => { + // Update the current flows with the new auth settings + const flowsMCPData: MCPSettingsType[] = + flowsMCP?.map((flow) => ({ + id: flow.id, + action_name: flow.action_name, + action_description: flow.action_description, + mcp_enabled: flow.mcp_enabled, + })) || []; + + const requestData = { + settings: flowsMCPData, + auth_settings: authSettings, + }; + + patchFlowsMCP(requestData); }; const flowsMCPData = flowsMCP?.map((flow) => ({ @@ -187,10 +225,93 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { const apiUrl = customGetMCPUrl(projectId); + // Generate auth headers based on the authentication type + const getAuthHeaders = () => { + // If MCP auth is disabled, use the previous API key behavior + if (!ENABLE_MCP_COMPOSER) { + if (isAutoLogin) return ""; + return ` + "--headers", + "x-api-key", + "${apiKey || "YOUR_API_KEY"}",`; + } + + if (!currentAuthSettings || currentAuthSettings.auth_type === "none") { + return ""; + } + + switch (currentAuthSettings.auth_type) { + case "apikey": + return ` + "--headers", + "x-api-key", + "${currentAuthSettings.api_key || "YOUR_API_KEY"}",`; + case "basic": + return ` + "--headers", + "Authorization", + "Basic ${btoa( + `${currentAuthSettings.username || "USERNAME"}:${ + currentAuthSettings.password || "PASSWORD" + }`, + )}",`; + case "bearer": + return ` + "--headers", + "Authorization", + "Bearer ${currentAuthSettings.bearer_token || "YOUR_BEARER_TOKEN"}",`; + case "iam": + return ` + "--headers", + "x-api-key", + "${currentAuthSettings.api_key || "YOUR_IAM_TOKEN"}", + "--headers", + "x-iam-endpoint", + "${currentAuthSettings.iam_endpoint || "YOUR_IAM_ENDPOINT"}",`; + case "oauth": + return ` + "--auth-type", + "oauth"`; + default: + return ""; + } + }; + + const getEnvVars = () => { + if (!ENABLE_MCP_COMPOSER || currentAuthSettings?.auth_type === "none") + return ""; + if (currentAuthSettings?.auth_type === "oauth") { + return ` + "env": { + "OAUTH_HOST": "${currentAuthSettings.oauth_host || "YOUR_OAUTH_HOST"}", + "OAUTH_PORT": "${currentAuthSettings.oauth_port || "YOUR_OAUTH_PORT"}", + "OAUTH_SERVER_URL": "${currentAuthSettings.oauth_server_url || "YOUR_OAUTH_SERVER_URL"}", + "OAUTH_CALLBACK_PATH": "${currentAuthSettings.oauth_callback_path || "YOUR_OAUTH_CALLBACK_PATH"}", + "OAUTH_CLIENT_ID": "${currentAuthSettings.oauth_client_id || "YOUR_OAUTH_CLIENT_ID"}", + "OAUTH_CLIENT_SECRET": "${currentAuthSettings.oauth_client_secret || "YOUR_OAUTH_CLIENT_SECRET"}", + "OAUTH_AUTH_URL": "${currentAuthSettings.oauth_auth_url || "YOUR_OAUTH_AUTH_URL"}", + "OAUTH_TOKEN_URL": "${currentAuthSettings.oauth_token_url || "YOUR_OAUTH_TOKEN_URL"}", + "OAUTH_MCP_SCOPE": "${currentAuthSettings.oauth_mcp_scope || "YOUR_OAUTH_MCP_SCOPE"}", + "OAUTH_PROVIDER_SCOPE": "${currentAuthSettings.oauth_provider_scope || "YOUR_OAUTH_PROVIDER_SCOPE"}", + }`; + } + return ""; + }; + const MCP_SERVER_JSON = `{ "mcpServers": { - "lf-${parseString(folderName ?? "project", ["snake_case", "no_blank", "lowercase"]).slice(0, MAX_MCP_SERVER_NAME_LENGTH - 4)}": { - "command": "${selectedPlatform === "windows" ? "cmd" : selectedPlatform === "wsl" ? "wsl" : "uvx"}", + "lf-${parseString(folderName ?? "project", [ + "snake_case", + "no_blank", + "lowercase", + ]).slice(0, MAX_MCP_SERVER_NAME_LENGTH - 4)}": { + "command": "${ + selectedPlatform === "windows" + ? "cmd" + : selectedPlatform === "wsl" + ? "wsl" + : "uvx" + }", "args": [ ${ selectedPlatform === `windows` @@ -201,16 +322,9 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { ? `"uvx", ` : "" - }"mcp-proxy",${ - isAutoLogin - ? "" - : ` - "--headers", - "x-api-key", - "${apiKey || "YOUR_API_KEY"}",` - } + }"${ENABLE_MCP_COMPOSER ? "mcp-composer" : "mcp-proxy"}",${getAuthHeaders()} "${apiUrl}" - ] + ]${ENABLE_MCP_COMPOSER && currentAuthSettings?.auth_type === "oauth" ? `,` : ""}${getEnvVars()} } } }`; @@ -239,7 +353,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { .then((res) => { setApiKey(res["api_key"]); }) - .catch((err) => {}) + .catch(() => {}) .finally(() => { setIsGeneratingApiKey(false); }); @@ -247,42 +361,35 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { const [loadingMCP, setLoadingMCP] = useState([]); + // Check if authentication is configured (not "none") + const hasAuthentication = + currentAuthSettings?.auth_type && currentAuthSettings.auth_type !== "none"; + return (
-
- MCP Server -
-
- Access your Project's flows as Tools within a MCP Server. Learn more in - our - - {" "} - Projects as MCP Servers guide. - +
+
+
+ MCP Server +
+
+ Access your Project's flows as Tools within a MCP Server. Learn more + in our + + {" "} + Projects as MCP Servers guide. + +
+
-
- -
- Flows/Tools -
-
-
-
+
{
-
+ {ENABLE_MCP_COMPOSER && ( +
+ + Auth: + {!hasAuthentication ? ( + + + None (public) + + ) : ( + + + {AUTH_METHODS[ + currentAuthSettings.auth_type as keyof typeof AUTH_METHODS + ]?.label || currentAuthSettings.auth_type} + + )} + + +
+ )} +
- {[{ name: "Auto install" }, { name: "JSON" }].map( - (item, index) => ( - - ), - )} + {[{ name: "Auto install" }, { name: "JSON" }].map((item) => ( + + ))}
{selectedMode === "JSON" && ( @@ -325,10 +467,10 @@ const McpServerTab = ({ folderName }: { folderName: string }) => { onValueChange={setSelectedPlatform} > - {operatingSystemTabs.map((tab, index) => ( + {operatingSystemTabs.map((tab) => ( { )} {autoInstallers.map((installer) => (
+ {ENABLE_MCP_COMPOSER && ( + + )}
); }; diff --git a/src/frontend/src/types/mcp/index.ts b/src/frontend/src/types/mcp/index.ts index 329a88148e12..aed741c45dfb 100644 --- a/src/frontend/src/types/mcp/index.ts +++ b/src/frontend/src/types/mcp/index.ts @@ -1,3 +1,22 @@ +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; + oauth_callback_path?: string; + oauth_client_id?: string; + oauth_client_secret?: string; + oauth_auth_url?: string; + oauth_token_url?: string; + oauth_mcp_scope?: string; + oauth_provider_scope?: string; +}; + export type MCPSettingsType = { id: string; mcp_enabled: boolean; @@ -8,6 +27,11 @@ export type MCPSettingsType = { input_schema?: Record; }; +export type MCPProjectResponseType = { + tools: MCPSettingsType[]; + auth_settings?: AuthSettingsType; +}; + export type MCPServerInfoType = { id?: string; name: string; diff --git a/src/frontend/src/utils/mcpUtils.ts b/src/frontend/src/utils/mcpUtils.ts index e1b2667b1346..f1a109493228 100644 --- a/src/frontend/src/utils/mcpUtils.ts +++ b/src/frontend/src/utils/mcpUtils.ts @@ -1,5 +1,28 @@ import { MCPServerType } from "@/types/mcp"; +export enum AuthMethodId { + NONE = "none", + API_KEY = "apikey", + BASIC = "basic", + BEARER = "bearer", + IAM = "iam", + OAUTH = "oauth", +} + +export const AUTH_METHODS = { + [AuthMethodId.NONE]: { id: AuthMethodId.NONE, label: "None" }, + [AuthMethodId.API_KEY]: { id: AuthMethodId.API_KEY, label: "API Key" }, + [AuthMethodId.BASIC]: { + id: AuthMethodId.BASIC, + label: "Basic", + }, + [AuthMethodId.BEARER]: { id: AuthMethodId.BEARER, label: "Bearer Token" }, + [AuthMethodId.IAM]: { id: AuthMethodId.IAM, label: "IAM" }, + [AuthMethodId.OAUTH]: { id: AuthMethodId.OAUTH, label: "OAuth" }, +} as const; + +export const AUTH_METHODS_ARRAY = Object.values(AUTH_METHODS); + /** * Extracts all MCP servers from a JSON string or object. * Supports: diff --git a/src/frontend/vite.config.mts b/src/frontend/vite.config.mts index 4b7bee6355b9..662a3270bec9 100644 --- a/src/frontend/vite.config.mts +++ b/src/frontend/vite.config.mts @@ -53,6 +53,9 @@ export default defineConfig(({ mode }) => { "process.env.LANGFLOW_AUTO_LOGIN": JSON.stringify( envLangflow.LANGFLOW_AUTO_LOGIN ?? true, ), + "process.env.LANGFLOW_FEATURE_MCP_COMPOSER": JSON.stringify( + envLangflow.LANGFLOW_FEATURE_MCP_COMPOSER ?? "false", + ), }, plugins: [react(), svgr(), tsconfigPaths()], server: {