feat: add servers persistence to MCP connection component, add MCP connections settings page (#8388)

* Added mcpinput to the backend

* Fixed list selection component to contain descriptions

* Added mcp component in the frontend with mock values

* Added mcp rendering on Parameter Render Component

* Changed input to be more concise and to have dynamic placeholder

* Added header search placeholder

* Fixed styling to match new input

* Removed unused params

* Adds AddMcpServerModal's first mock version

* Adds Add button on mcp component and list selection component

* First pass at mcp api

* Add PATCH endpoint

* Add DELETE endpoint

* fix: Bump version numbers for langflow and langflow-base to 1.4.3 and 0.4.3 respectively

* fix: Remove Igor Carvalho from maintainers list in pyproject.toml

* fix(agent): reset model list when provider changes

Switching the provider in the Agent component sometimes left models from
the previous provider visible/selected.
We now filter  against the new , ensuring only
models that belong to the active provider remain.

* src/frontend/src/components/core/dropdownComponent/index.tsx
  – add  guard when rebuilding

* tests/extended/regression/general-bugs-dropdown-select-not-in-list.spec.ts
  – expand coverage for “model not in list” edge-cases

Co-authored-by: Cristian Lousa <cristian.lousa@gmail.com>

* fix: Update Pokédex Agent template (#8373)

* Implement adding and getting MCP servers, implemented addMcpServerModal

* Added sse and stdio ways of adding a server

* Added no actions handling

* added new mcp type to constants

* Added headers to add mcp server modal

* Changed mcp component to allow persistent mcp servers

* fix input list component gradient

* fix add server modal to patch when initial data is present, and to clean variables when switching tabs

* changed message on add mcp server

* Added required mutations for mcp page

* Added mcp servers page

* Changed design of page

* Fixed delete problems and added delete confirmation

* fixed wrong error parsing

* changed padding

* Made added server be used on mcp component

* refactor: remove references to the langflow store (#8354)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: KimberlyFields <46325568+KimberlyFields@users.noreply.github.com>
Co-authored-by: Ítalo Johnny <italojohnnydosanjos@gmail.com>
Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com>
fix: apikey lock issue and add option to disable tracking (#8361)

* Fixed search on sidebar

* fixed infinite use effect

* Fixed error handling

* Fixed tool mode disappearing

* fixed key pair button submitting form

* Fixed bugs

* Added required

* Changed message

* Disabled other tabs when modifying

* Removed tool dropdown if the mcp server is empty

* parsed name

* fixed data test id not applying

* fixed mcp component

* Fixed component not working when only stdio command is present

* refactored tests

* Updated mcp_component to remove old non default keys

* Added data-testids

* Modified tests to include settings page functionality

* [autofix.ci] apply automated fixes

* Refactor out the core part of the mcp

* [autofix.ci] apply automated fixes

* Added placeholders on frontend components for errors

* Fixed bugs with mcp component

* updated bug

* fix: made empty project appear instead of empty flows list when mcp is enabled (#8336)

* try to fix

* Fix MCP persistence

* Update mcp_component.py

* Update mcp.py

* [autofix.ci] apply automated fixes

* fix: Bump version numbers for langflow and langflow-base to 1.4.3 and 0.4.3 respectively

* fix: Remove Igor Carvalho from maintainers list in pyproject.toml

* fix(agent): reset model list when provider changes

Switching the provider in the Agent component sometimes left models from
the previous provider visible/selected.
We now filter  against the new , ensuring only
models that belong to the active provider remain.

* src/frontend/src/components/core/dropdownComponent/index.tsx
  – add  guard when rebuilding

* tests/extended/regression/general-bugs-dropdown-select-not-in-list.spec.ts
  – expand coverage for “model not in list” edge-cases

Co-authored-by: Cristian Lousa <cristian.lousa@gmail.com>

* refactor: remove references to the langflow store (#8354)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: KimberlyFields <46325568+KimberlyFields@users.noreply.github.com>
Co-authored-by: Ítalo Johnny <italojohnnydosanjos@gmail.com>
Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com>
fix: apikey lock issue and add option to disable tracking (#8361)

* fix: made empty project appear instead of empty flows list when mcp is enabled (#8336)

* fix mcp client async problems

* fixed mcp sse access

* [autofix.ci] apply automated fixes

* Made values be maintained when refreshing page

* Fixed bugs with tool mode and switching from tool mode to not tool mode

* Update mcp_component.py

* Update test_mcp_component.py

* Don't expose file by name as external endpoint

* Update files.py

* Update files.py

* Add checks for id

* Refactor tests

* Update test_mcp_component.py

* Update test_mcp_component.py

* Update test_mcp_component.py

* updated tests

* re-added placeholder on input for tests to not fail

* updated session selector in order for tests to work

---------

Co-authored-by: Eric Hare <ericrhare@gmail.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
Co-authored-by: Mike Fortman <michael.fortman@datastax.com>
Co-authored-by: Cristian Lousa <cristian.lousa@gmail.com>
Co-authored-by: Edwin Jose <edwin.jose@datastax.com>
Co-authored-by: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: KimberlyFields <46325568+KimberlyFields@users.noreply.github.com>
Co-authored-by: Ítalo Johnny <italojohnnydosanjos@gmail.com>
Co-authored-by: Mendon Kissling <59585235+mendonk@users.noreply.github.com>
This commit is contained in:
Lucas Oliveira 2025-06-11 16:21:38 -03:00 • committed by GitHub
commit 60ccdb500f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2381 additions and 876 deletions

View file

@ -1,12 +1,12 @@
{
"name": "langflow",
"version": "1.4.2",
"version": "1.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "langflow",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"@chakra-ui/number-input": "^2.1.2",
"@headlessui/react": "^2.0.4",
@ -755,7 +755,6 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {

View file

@ -1,6 +1,6 @@
{
"name": "langflow",
"version": "1.4.2",
"version": "1.4.3",
"private": true,
"dependencies": {
"@chakra-ui/number-input": "^2.1.2",
@ -146,4 +146,4 @@
"ua-parser-js": "^1.0.38",
"vite": "^5.4.19"
}
}
}

View file

@ -71,18 +71,20 @@ const ListItem = ({
// Disable pointer events during keyboard navigation
style={{ pointerEvents: isKeyboardNavActive ? "none" : "auto" }}
>
<div className="flex w-full items-center gap-2">
<div className="flex w-full items-center gap-3">
{item.icon && (
<div>
<ForwardedIconComponent
name={formattedIcon}
className="mr-2 h-4 w-4"
/>
<ForwardedIconComponent name={formattedIcon} className="h-4 w-4" />
</div>
)}
<div className="flex w-full flex-col truncate">
<div className="flex w-full truncate text-mmd font-semibold">
<div className="flex w-full items-center gap-2 truncate text-mmd font-medium">
<span className="truncate">{item.name}</span>
{"description" in item && item.description && (
<span className="font-normal text-muted-foreground">
{item.description}
</span>
)}
</div>
{"metaData" in item && item.metaData && (
<div className="flex w-full truncate text-mmd text-gray-500">

View file

@ -1,9 +1,11 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import SearchBarComponent from "@/components/core/parameterRenderComponent/components/searchBarComponent";
import { InputProps } from "@/components/core/parameterRenderComponent/types";
import { DialogHeader } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogHeader } from "@/components/ui/dialog";
import { Dialog, DialogContent } from "@/components/ui/dialog-with-no-close";
import { testIdCase } from "@/utils/utils";
import { Input } from "@/components/ui/input";
import { cn, testIdCase } from "@/utils/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ListItem from "./ListItem";
@ -17,6 +19,9 @@ interface ListSelectionComponentProps {
searchCategories?: string[];
onSelection?: (action: any) => void;
limit?: number;
headerSearchPlaceholder?: string;
addButtonText?: string;
onAddButtonClick?: () => void;
}
const ListSelectionComponent = ({
@ -28,6 +33,9 @@ const ListSelectionComponent = ({
selectedList = [],
options,
limit = 1,
headerSearchPlaceholder = "Search...",
addButtonText,
onAddButtonClick,
...baseInputProps
}: InputProps<any, ListSelectionComponentProps>) => {
const { nodeClass } = baseInputProps;
@ -164,29 +172,43 @@ const ListSelectionComponent = ({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="flex max-h-[65vh] min-h-[15vh] flex-col rounded-xl p-0"
className="flex max-h-[65vh] min-h-[15vh] flex-col overflow-hidden rounded-xl p-0"
onKeyDown={handleKeyDown}
>
<DialogHeader className="flex w-full justify-between border-b px-3 py-3">
<div className="flex items-center gap-2">
<ForwardedIconComponent
name={nodeClass?.icon || "unknown"}
className="h-[18px] w-[18px] text-muted-foreground"
/>
<div className="text-[13px] font-semibold">
{nodeClass?.display_name}
<DialogHeader className="flex w-full justify-between border-b p-2">
{nodeClass ? (
<div className="flex items-center gap-2 p-1">
<ForwardedIconComponent
name={nodeClass?.icon || "unknown"}
className="h-[18px] w-[18px] text-muted-foreground"
/>
<div className="text-[13px] font-semibold">
{nodeClass?.display_name}
</div>
</div>
</div>
) : (
<div className="relative text-[13px] font-normal">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border-none focus:ring-0"
placeholder={headerSearchPlaceholder}
data-testid="search_bar_input"
/>
</div>
)}
</DialogHeader>
{(filteredList?.length > 20 || search) && (
<div className="flex w-full items-center justify-between px-3">
<SearchBarComponent
searchCategories={searchCategories}
search={search}
setSearch={setSearch}
/>
</div>
)}
{(filteredList?.length > 20 || search) &&
!headerSearchPlaceholder &&
!nodeClass && (
<div className="flex w-full items-center justify-between px-3">
<SearchBarComponent
searchCategories={searchCategories}
search={search}
setSearch={setSearch}
/>
</div>
)}
<div
ref={listContainerRef}
@ -226,6 +248,16 @@ const ListSelectionComponent = ({
</div>
)}
</div>
<DialogFooter>
<Button
className="flex w-full items-center gap-2 border-t px-4 py-3 !text-mmd hover:bg-muted"
unstyled
onClick={onAddButtonClick}
>
<ForwardedIconComponent name="Plus" className="h-4 w-4" />
{addButtonText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -45,6 +45,7 @@ export default function Dropdown({
optionsMetaData,
combobox,
onSelect,
placeholder,
editNode = false,
id = "",
children,
@ -330,7 +331,7 @@ export default function Dropdown({
<>
{value && filteredOptions.includes(value)
? value
: SELECT_AN_OPTION}{" "}
: placeholder || SELECT_AN_OPTION}{" "}
</>
)}
</span>

View file

@ -473,7 +473,8 @@ const SideBarFoldersButtonsComponent = ({
{ENABLE_FILE_MANAGEMENT && (
<SidebarFooter className="border-t">
<div className="grid w-full items-center gap-2 p-2">
{!ENABLE_DATASTAX_LANGFLOW && <CustomStoreButton />}
{/* TODO: Remove this on cleanup */}
{ENABLE_DATASTAX_LANGFLOW && <CustomStoreButton />}
<SidebarMenuButton
isActive={checkPathFiles}
onClick={() => handleFilesClick?.()}

View file

@ -124,7 +124,7 @@ export default function ToolsComponent({
{visibleActions.length === 0 && !isAction && value && (
<Button
disabled={disabled}
disabled={disabled || value.length === 0}
size={editNode ? "xs" : "default"}
className={
"w-full " +
@ -132,7 +132,9 @@ export default function ToolsComponent({
}
onClick={() => setIsModalOpen(true)}
>
<span>Select actions</span>
<span>
{value.length === 0 ? "No actions available" : "Select actions"}
</span>
</Button>
)}
</div>

View file

@ -13,6 +13,7 @@ export default function DropdownComponent({
name,
dialogInputs,
optionsMetaData,
placeholder,
nodeClass,
nodeId,
handleNodeClass,
@ -38,6 +39,7 @@ export default function DropdownComponent({
handleNodeClass={handleNodeClass}
optionsMetaData={optionsMetaData}
onSelect={onChange}
placeholder={placeholder}
combobox={combobox}
value={value || (toggleValue === false && toggle ? options[0] : "")}
id={`dropdown_${id}`}

View file

@ -27,7 +27,7 @@ export const ButtonInputList = ({
<div
onClick={addNewInput}
className={cn(
"hit-area-icon group absolute flex -translate-y-8 translate-x-[15.36rem] items-center justify-center bg-background text-center hover:bg-muted",
"hit-area-icon group absolute -top-8 right-0 flex items-center justify-center bg-background text-center hover:bg-muted",
disabled
? "pointer-events-none bg-background hover:bg-background"
: "",

View file

@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "../../../../ui/input";
import { ButtonInputList } from "./components/button-input-list";
import { GRADIENT_CLASS } from "@/constants/constants";
import { cn } from "../../../../../utils/utils";
import { getPlaceholder } from "../../helpers/get-placeholder-disabled";
import { InputListComponentType, InputProps } from "../../types";
@ -78,7 +77,7 @@ export default function InputListComponent({
// );
return (
<div className={cn("w-full", editNode && "max-h-52")}>
<div className={cn("relative w-full", editNode && "max-h-52")}>
{!editNode && !disabled && (
<ButtonInputList
index={0}
@ -90,22 +89,9 @@ export default function InputListComponent({
/>
)}
<div className="mt-2 flex w-full flex-col gap-3">
<div className="flex w-full flex-col gap-2">
{value.map((singleValue, index) => (
<div key={index} className="flex w-full items-center">
{focusedIndex !== index && !disabled && (
<div
className={cn(
"absolute z-50 h-6 w-16",
editNode ? "translate-x-[12rem]" : "translate-x-[11.1rem]",
)}
style={{
pointerEvents: "none",
background: GRADIENT_CLASS,
}}
aria-hidden="true"
/>
)}
<div className="group relative flex-1">
<Input
ref={index === 0 ? inputRef : null}
@ -113,7 +99,8 @@ export default function InputListComponent({
type="text"
value={singleValue}
className={cn(
"w-full pr-10 text-primary",
"w-full text-primary",
value.length > 1 && "pr-10",
editNode ? "input-edit-node" : "",
disabled ? "disabled-state" : "",
)}
@ -137,20 +124,18 @@ export default function InputListComponent({
/>
</div>
)}
{/*
We will add this back in a future release
{!disabled && (
<DropdownMenuInputList
index={index}
dropdownOpen={dropdownOpen!}
setDropdownOpen={setDropdownOpen}
editNode={editNode}
handleDuplicateInput={handleDuplicateInput}
removeInput={removeInput}
canDelete={value.length > 1}
/>
)} */}
{focusedIndex !== index && !disabled && (
<div className="pointer-events-none absolute top-1/2 flex w-full -translate-y-1/2">
<div
className={cn(
"flex-1 cursor-text select-text text-nowrap pl-3 text-sm text-muted-foreground truncate-background",
value.length > 1 ? "mr-10" : "mr-3",
)}
>
<span className="opacity-0">{singleValue}</span>
</div>
</div>
)}
</div>
</div>
))}

View file

@ -0,0 +1,125 @@
import { useGetMCPServers } from "@/controllers/API/queries/mcp/use-get-mcp-servers";
import AddMcpServerModal from "@/modals/addMcpServerModal";
import { useEffect, useMemo, useRef, useState } from "react";
import ListSelectionComponent from "../../../../../CustomNodes/GenericNode/components/ListSelectionComponent";
import { cn } from "../../../../../utils/utils";
import { default as ForwardedIconComponent } from "../../../../common/genericIconComponent";
import { Button } from "../../../../ui/button";
import { InputProps } from "../../types";
export default function McpComponent({
value,
disabled,
handleOnNewValue,
editNode = false,
id = "",
}: InputProps<string, any>): JSX.Element {
const { data: mcpServers } = useGetMCPServers();
const options = useMemo(
() =>
mcpServers?.map((server) => ({
name: server.name,
description: !server.toolsCount
? "No actions found"
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`,
})),
[mcpServers],
);
const [open, setOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<any[]>([]);
// Initialize selected item from value on mount or value/options change
useEffect(() => {
const selectedOption = value
? options?.find((option) => option.name === value)
: null;
setSelectedItem(
selectedOption ? [{ name: selectedOption.name }] : [{ name: "" }],
);
if (value !== selectedOption?.name) {
handleOnNewValue({ value: "" }, { skipSnapshot: true });
}
}, [value, options]);
// Handle selection from dialog
const handleSelection = (item: any) => {
setSelectedItem([{ name: item.name }]);
handleOnNewValue({ value: item.name }, { skipSnapshot: true });
setOpen(false);
};
const handleAddButtonClick = () => {
setAddOpen(true);
};
const handleOpenListSelectionDialog = () => setOpen(true);
const handleCloseListSelectionDialog = () => setOpen(false);
const handleSuccess = (server: string) => {
handleOnNewValue({ value: server });
setOpen(false);
};
return (
<div className="flex w-full flex-col gap-2">
{options && (
<>
{options.length > 0 ? (
<Button
variant="primary"
size="xs"
role="combobox"
onClick={handleOpenListSelectionDialog}
className="dropdown-component-outline input-edit-node w-full py-2"
data-testid="mcp-server-dropdown"
disabled={disabled}
>
<div
className={cn(
"flex w-full items-center justify-start text-sm font-normal",
)}
>
<span className="truncate">
{selectedItem[0]?.name
? selectedItem[0]?.name
: "Select a server..."}
</span>
<ForwardedIconComponent
name="ChevronsUpDown"
className="ml-auto h-5 w-5 text-muted-foreground"
/>
</div>
</Button>
) : (
<Button size="sm" onClick={handleAddButtonClick}>
<span>Add MCP Server</span>
</Button>
)}
<ListSelectionComponent
open={open}
onClose={handleCloseListSelectionDialog}
onSelection={handleSelection}
setSelectedList={setSelectedItem}
selectedList={selectedItem}
options={options}
limit={1}
id={id}
value={value}
editNode={editNode}
headerSearchPlaceholder="Search MCP Servers..."
handleOnNewValue={handleOnNewValue}
disabled={disabled}
addButtonText="Add MCP Server"
onAddButtonClick={handleAddButtonClick}
/>
</>
)}
<AddMcpServerModal
open={addOpen}
setOpen={setAddOpen}
onSuccess={handleSuccess}
/>
</div>
);
}

View file

@ -72,6 +72,7 @@ export function StrRenderComponent({
options={templateData.options ?? []}
nodeId={nodeId}
nodeClass={nodeClass}
placeholder={placeholder}
handleNodeClass={handleNodeClass}
optionsMetaData={templateData.options_metadata}
combobox={templateData.combobox}

View file

@ -16,7 +16,7 @@ import InputFileComponent from "./components/inputFileComponent";
import InputListComponent from "./components/inputListComponent";
import IntComponent from "./components/intComponent";
import KeypairListComponent from "./components/keypairListComponent";
import LinkComponent from "./components/linkComponent";
import McpComponent from "./components/mcpComponent";
import MultiselectComponent from "./components/multiselectComponent";
import PromptAreaComponent from "./components/promptComponent";
import QueryComponent from "./components/queryComponent";
@ -280,6 +280,16 @@ export function ParameterRenderComponent({
id={`query_${id}`}
/>
);
case "mcp":
return (
<McpComponent
{...baseInputProps}
id={`mcp_${id}`}
editNode={editNode}
disabled={disabled}
value={templateValue}
/>
);
default:
return <EmptyParameterComponent {...baseInputProps} />;
}

View file

@ -6,44 +6,50 @@ export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
icon?: string;
inputClassName?: string;
placeholder?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, inputClassName, icon = "", type, ...props }, ref) => {
if (icon) {
return (
<label className={cn("relative block h-fit w-full", className)}>
(
{ className, inputClassName, icon = "", type, placeholder, ...props },
ref,
) => {
return (
<label
className={cn(
"relative block h-fit w-full text-sm",
icon ? className : "",
)}
>
{icon && (
<ForwardedIconComponent
name={icon}
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
/>
<input
autoComplete="off"
data-testid=""
type={type}
className={cn(
"nopan nodelete nodrag noflow form-input block w-full appearance-none truncate rounded-md border-border bg-background px-3 pl-9 text-left text-sm placeholder:text-muted-foreground focus:border-black focus:placeholder-transparent focus:ring-zinc-300 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:border-white dark:focus:ring-zinc-800",
inputClassName,
)}
ref={ref}
{...props}
/>
</label>
);
} else {
return (
)}
<input
data-testid=""
autoComplete="off"
type={type}
placeholder={placeholder}
className={cn(
"nopan nodelete nodrag noflow primary-input",
className,
"nopan nodelete nodrag noflow primary-input placeholder-opacity-0",
icon && "pl-9",
icon ? inputClassName : className,
)}
ref={ref}
{...props}
/>
);
}
<span
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 pl-px text-placeholder-foreground",
icon ? "left-9" : "left-3",
props.value && "hidden",
)}
>
{placeholder}
</span>
</label>
);
},
);
Input.displayName = "Input";

View file

@ -660,6 +660,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
"connect",
"auth",
"query",
"mcp",
"tools",
]);

View file

@ -28,6 +28,7 @@ export const URLs = {
VOICE: `voice`,
PUBLIC_FLOW: `flows/public_flow`,
MCP: `mcp/project`,
MCP_SERVERS: `mcp/servers`,
} as const;
// IMPORTANT: FOLDERS endpoint now points to 'projects' for backward compatibility

View file

@ -0,0 +1,66 @@
import { useMutationFunctionType } from "@/types/api";
import { MCPServerType } from "@/types/mcp";
import { UseMutationResult } from "@tanstack/react-query";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface AddMCPServerResponse {
message: string;
}
export const useAddMCPServer: useMutationFunctionType<
undefined,
MCPServerType,
AddMCPServerResponse
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor();
async function addMCPServer(
body: MCPServerType,
): Promise<AddMCPServerResponse> {
try {
let payload: Omit<MCPServerType, "name"> = {};
if (body.url) {
payload.url = body.url;
}
if (body.command) {
payload.command = body.command;
}
if (body.args && body.args.length > 0) {
payload.args = body.args;
}
if (body.env && Object.keys(body.env).length > 0) {
payload.env = body.env;
}
const res = await api.post(
`${getURL("MCP_SERVERS", undefined, true)}/${body.name}`,
payload,
);
return { message: res.data?.message || "MCP Server added successfully" };
} catch (error: any) {
// Transform the error to include a message that can be handled by the UI
const errorMessage =
error.response?.data?.detail ||
error.message ||
"Failed to install MCP";
throw new Error(errorMessage);
}
}
const mutation: UseMutationResult<AddMCPServerResponse, any, MCPServerType> =
mutate(["useAddMCPServer"], addMCPServer, {
...options,
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ["useGetMCPServers"],
});
options?.onSuccess?.(data, variables, context);
},
});
return mutation;
};

View file

@ -0,0 +1,59 @@
import { useMutationFunctionType } from "@/types/api";
import { MCPServerType } from "@/types/mcp";
import { UseMutationResult } from "@tanstack/react-query";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface DeleteMCPServerResponse {
message: string;
}
interface DeleteMCPServerType {
name: string;
}
export const useDeleteMCPServer: useMutationFunctionType<
undefined,
DeleteMCPServerType,
DeleteMCPServerResponse
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor();
async function deleteMCPServer(
payload: MCPServerType,
): Promise<DeleteMCPServerResponse> {
try {
const res = await api.delete(
`${getURL("MCP_SERVERS", undefined, true)}/${payload.name}`,
);
return {
message: res.data?.message || "MCP Server deleted successfully",
};
} catch (error: any) {
// Transform the error to include a message that can be handled by the UI
const errorMessage =
error.response?.data?.detail ||
error.message ||
"Failed to delete MCP Server";
throw new Error(errorMessage);
}
}
const mutation: UseMutationResult<
DeleteMCPServerResponse,
any,
MCPServerType
> = mutate(["useDeleteMCPServer"], deleteMCPServer, {
...options,
onSuccess: (data, variables, context) => {
queryClient.refetchQueries({
queryKey: ["useGetMCPServers"],
});
options?.onSuccess?.(data, variables, context);
},
});
return mutation;
};

View file

@ -0,0 +1,33 @@
import { useMutationFunctionType } from "@/types/api";
import { MCPServerType } from "@/types/mcp";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
type getMCPServerResponse = MCPServerType;
interface IGetMCPServer {
name: string;
}
export const useGetMCPServer: useMutationFunctionType<
undefined,
IGetMCPServer,
getMCPServerResponse
> = (options) => {
const { mutate } = UseRequestProcessor();
const responseFn = async (params: IGetMCPServer) => {
const { data } = await api.get<Omit<getMCPServerResponse, "name">>(
`${getURL("MCP_SERVERS", undefined, true)}/${params.name}`,
);
return { ...data, name: params.name };
};
const queryResult = mutate(["useGetMCPServer"], responseFn, {
...options,
});
return queryResult;
};

View file

@ -0,0 +1,32 @@
import { useQueryFunctionType } from "@/types/api";
import { MCPServerInfoType } from "@/types/mcp";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
type getMCPServersResponse = Array<MCPServerInfoType>;
export const useGetMCPServers: useQueryFunctionType<
undefined,
getMCPServersResponse
> = (options) => {
const { query } = UseRequestProcessor();
const responseFn = async () => {
try {
const { data } = await api.get<getMCPServersResponse>(
`${getURL("MCP_SERVERS", undefined, true)}`,
);
return data;
} catch (error) {
console.error(error);
return [];
}
};
const queryResult = query(["useGetMCPServers"], responseFn, {
...options,
});
return queryResult;
};

View file

@ -0,0 +1,74 @@
import { useMutationFunctionType } from "@/types/api";
import { MCPServerType } from "@/types/mcp";
import { UseMutationResult } from "@tanstack/react-query";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface PatchMCPServerResponse {
message: string;
}
export const usePatchMCPServer: useMutationFunctionType<
undefined,
MCPServerType,
PatchMCPServerResponse
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor();
async function patchMCPServer(
body: MCPServerType,
): Promise<PatchMCPServerResponse> {
try {
let payload: Omit<MCPServerType, "name"> = {};
if (body.url) {
payload.url = body.url;
}
if (body.command) {
payload.command = body.command;
}
if (body.args && body.args.length > 0) {
payload.args = body.args;
}
if (body.env && Object.keys(body.env).length > 0) {
payload.env = body.env;
}
const res = await api.patch(
`${getURL("MCP_SERVERS", undefined, true)}/${body.name}`,
payload,
);
return {
message: res.data?.message || "MCP Server patched successfully",
};
} catch (error: any) {
// Transform the error to include a message that can be handled by the UI
const errorMessage =
error.response?.data?.detail ||
error.message ||
"Failed to patch MCP Server";
throw new Error(errorMessage);
}
}
const mutation: UseMutationResult<
PatchMCPServerResponse,
any,
MCPServerType
> = mutate(["usePatchMCPServer"], patchMCPServer, {
...options,
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: ["useGetMCPServers"],
});
queryClient.invalidateQueries({
queryKey: ["useGetMCPServer", data.name],
});
options?.onSuccess?.(data, variables, context);
},
});
return mutation;
};

View file

@ -1,8 +1,13 @@
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
export const CustomStoreSidebar = () => {
return [
{
export const CustomStoreSidebar = (
hasApiKey: boolean = false,
hasStore: boolean = false,
) => {
const items: Array<{ title: string; href: string; icon: JSX.Element }> = [];
if (hasApiKey) {
items.push({
title: "Langflow API Keys",
href: "/settings/api-keys",
icon: (
@ -11,8 +16,11 @@ export const CustomStoreSidebar = () => {
className="w-4 flex-shrink-0 justify-start stroke-[1.5]"
/>
),
},
{
});
}
if (hasStore) {
items.push({
title: "Langflow Store",
href: "/settings/store",
icon: (
@ -21,6 +29,8 @@ export const CustomStoreSidebar = () => {
className="w-4 flex-shrink-0 justify-start stroke-[1.5]"
/>
),
},
];
});
}
return items;
};

View file

@ -1,6 +1,6 @@
export const ENABLE_DARK_MODE = true;
export const ENABLE_API = true;
export const ENABLE_LANGFLOW_STORE = true;
export const ENABLE_LANGFLOW_STORE = false;
export const ENABLE_PROFILE_ICONS = true;
export const ENABLE_SOCIAL_LINKS = true;
export const ENABLE_BRANDING = true;

View file

@ -68,6 +68,7 @@ const IOKeyPairInput = ({
{isList && isInputField && index === ref.current.length - 1 ? (
<button
type="button"
onClick={() => {
let newInputList = _.cloneDeep(ref.current);
newInputList.push({ "": "" });
@ -81,6 +82,7 @@ const IOKeyPairInput = ({
</button>
) : isList && isInputField ? (
<button
type="button"
onClick={() => {
let newInputList = _.cloneDeep(ref.current);
newInputList.splice(index, 1);

View file

@ -164,15 +164,24 @@ export default function SessionSelector({
</div>
) : (
<ShadTooltip styleClasses="z-50" content={session}>
<div
className={cn(
"w-full whitespace-nowrap group-hover:truncate-secondary-hover",
isVisible
? "truncate-secondary-hover"
: "truncate-muted dark:truncate-canvas",
)}
>
{session === currentFlowId ? "Default Session" : session}
<div className="relative w-full overflow-hidden">
<span className="w-full truncate">
{session === currentFlowId ? "Default Session" : session}
</span>
<div
className={cn(
"pointer-events-none absolute left-0 right-0 top-0 h-full whitespace-nowrap",
)}
>
<div
className={cn(
"h-full w-full group-hover:truncate-secondary-hover",
isVisible
? "truncate-secondary-hover"
: "truncate-muted dark:truncate-canvas",
)}
></div>
</div>
</div>
</ShadTooltip>
)}

View file

@ -0,0 +1,436 @@
import { useEffect, useState } from "react";
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
import InputListComponent from "@/components/core/parameterRenderComponent/components/inputListComponent";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs-button";
import { Textarea } from "@/components/ui/textarea";
import { useAddMCPServer } from "@/controllers/API/queries/mcp/use-add-mcp-server";
import { usePatchMCPServer } from "@/controllers/API/queries/mcp/use-patch-mcp-server";
import { CustomLink } from "@/customization/components/custom-link";
import BaseModal from "@/modals/baseModal";
import IOKeyPairInput from "@/modals/IOModal/components/IOFieldView/components/key-pair-input";
import { MCPServerType } from "@/types/mcp";
import { extractMcpServersFromJson } from "@/utils/mcpUtils";
import { parseString } from "@/utils/stringManipulation";
import {
useIsFetching,
usePrefetchQuery,
useQueryClient,
} from "@tanstack/react-query";
//TODO IMPLEMENT FORM LOGIC
export default function AddMcpServerModal({
children,
initialData,
open: myOpen,
setOpen: mySetOpen,
onSuccess,
}: {
children?: JSX.Element;
initialData?: MCPServerType;
open?: boolean;
setOpen?: (a: boolean | ((o?: boolean) => boolean)) => void;
onSuccess?: (server: string) => void;
}): JSX.Element {
const [open, setOpen] =
mySetOpen !== undefined && myOpen !== undefined
? [myOpen, mySetOpen]
: useState(false);
const [type, setType] = useState(
initialData ? (initialData.command ? "STDIO" : "SSE") : "JSON",
);
const [jsonValue, setJsonValue] = useState("");
const [error, setError] = useState<string | null>(null);
const { mutateAsync: addMCPServer, isPending: isAddPending } =
useAddMCPServer();
const { mutateAsync: patchMCPServer, isPending: isPatchPending } =
usePatchMCPServer();
const queryClient = useQueryClient();
const modifyMCPServer = initialData ? patchMCPServer : addMCPServer;
const isPending = isAddPending || isPatchPending;
const changeType = (type: string) => {
setType(type);
setError(null);
setJsonValue("");
setStdioName("");
setStdioCommand("");
setStdioArgs([""]);
setStdioEnv([{ "": "" }]);
setSseName("");
setSseUrl("");
setSseEnv([{ "": "" }]);
setSseHeaders([{ "": "" }]);
};
// STDIO state
const [stdioName, setStdioName] = useState(initialData?.name || "");
const [stdioCommand, setStdioCommand] = useState(initialData?.command || "");
const [stdioArgs, setStdioArgs] = useState<string[]>(
initialData?.args || [""],
);
const [stdioEnv, setStdioEnv] = useState<any>(
initialData?.env || [{ "": "" }],
);
// SSE state
const [sseName, setSseName] = useState(initialData?.name || "");
const [sseUrl, setSseUrl] = useState(initialData?.url || "");
const [sseEnv, setSseEnv] = useState<any>(initialData?.env || [{ "": "" }]);
const [sseHeaders, setSseHeaders] = useState<any>(
initialData?.headers || [{ "": "" }],
);
useEffect(() => {
if (open) {
setType(initialData ? (initialData.command ? "STDIO" : "SSE") : "JSON");
setError(null);
setJsonValue("");
setStdioName(initialData?.name || "");
setStdioCommand(initialData?.command || "");
setStdioArgs(initialData?.args || [""]);
setStdioEnv(initialData?.env || [{ "": "" }]);
setSseName(initialData?.name || "");
setSseUrl(initialData?.url || "");
setSseEnv(initialData?.env || [{ "": "" }]);
setSseHeaders(initialData?.headers || [{ "": "" }]);
}
}, [open]);
function parseEnvList(envList: any): Record<string, string> {
// envList is an array of objects with one key each
const env: Record<string, string> = {};
if (Array.isArray(envList)) {
envList.forEach((obj) => {
const key = Object.keys(obj)[0];
if (key && key.trim() !== "") {
env[key] = obj[key];
}
});
}
return env;
}
async function submitForm() {
setError(null);
if (type === "STDIO") {
if (!stdioName.trim() || !stdioCommand.trim()) {
setError("Name and command are required.");
return;
}
const name = parseString(stdioName, [
"snake_case",
"no_blank",
"lowercase",
]).slice(0, 20);
try {
await modifyMCPServer({
name,
command: stdioCommand,
args: stdioArgs.filter((a) => a.trim() !== ""),
env: parseEnvList(stdioEnv),
});
if (!initialData) {
await queryClient.setQueryData(["useGetMCPServers"], (old: any) => {
return [...old, { name, toolsCount: 0 }];
});
}
onSuccess?.(name);
setOpen(false);
setStdioName("");
setStdioCommand("");
setStdioArgs([""]);
setStdioEnv([{ "": "" }]);
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add MCP server.");
}
return;
}
if (type === "SSE") {
if (!sseName.trim() || !sseUrl.trim()) {
setError("Name and URL are required.");
return;
}
const name = parseString(sseName, [
"snake_case",
"no_blank",
"lowercase",
]).slice(0, 20);
try {
await modifyMCPServer({
name,
env: parseEnvList(sseEnv),
url: sseUrl,
headers: parseEnvList(sseHeaders),
});
if (!initialData) {
await queryClient.setQueryData(["useGetMCPServers"], (old: any) => {
return [...old, { name, toolsCount: 0 }];
});
}
onSuccess?.(name);
setOpen(false);
setSseName("");
setSseUrl("");
setSseEnv([{ "": "" }]);
setSseHeaders([{ "": "" }]);
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add MCP server.");
}
return;
}
// JSON mode (multi-server)
let servers: MCPServerType[];
try {
servers = extractMcpServersFromJson(jsonValue).map((server) => ({
...server,
name: parseString(server.name, [
"snake_case",
"no_blank",
"lowercase",
]).slice(0, 20),
}));
} catch (e: any) {
setError(e.message || "Invalid input");
return;
}
if (servers.length === 0) {
setError("No valid MCP server found in the input.");
return;
}
try {
await Promise.all(servers.map((server) => modifyMCPServer(server)));
if (!initialData) {
await queryClient.setQueryData(["useGetMCPServers"], (old: any) => {
return [
...old,
...servers.map((server) => ({
name: server.name,
toolsCount: 0,
})),
];
});
}
onSuccess?.(servers.map((server) => server.name)[0]);
setOpen(false);
setJsonValue("");
setError(null);
} catch (err: any) {
setError(err?.message || "Failed to add one or more MCP servers.");
}
}
return (
<BaseModal
open={open}
setOpen={setOpen}
size="x-small"
onSubmit={submitForm}
className="!p-0"
>
<BaseModal.Trigger>{children}</BaseModal.Trigger>
<BaseModal.Content className="flex flex-col justify-between overflow-hidden">
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="flex flex-col gap-3 p-4 tracking-normal">
<div className="flex items-center gap-2 text-sm font-medium">
<ForwardedIconComponent
name="Mcp"
className="h-4 w-4 text-primary"
aria-hidden="true"
/>
{initialData ? "Update MCP Server" : "Add MCP Server"}
</div>
<span className="text-mmd font-normal text-muted-foreground">
Save MCP Servers. Manage added connections in{" "}
<CustomLink className="underline" to="/settings/mcp-servers">
settings
</CustomLink>
.
</span>
</div>
<div className="flex h-full w-full flex-col gap-4 overflow-hidden">
<Tabs
defaultValue={type}
onValueChange={changeType}
className="w-full"
>
<div className="px-4">
<TabsList className="mb-4 grid w-full grid-cols-3">
<TabsTrigger
disabled={!!initialData && type !== "JSON"}
data-testid="json-tab"
value="JSON"
>
JSON
</TabsTrigger>
<TabsTrigger
data-testid="stdio-tab"
disabled={!!initialData && type !== "STDIO"}
value="STDIO"
>
STDIO
</TabsTrigger>
<TabsTrigger
data-testid="sse-tab"
disabled={!!initialData && type !== "SSE"}
value="SSE"
>
SSE
</TabsTrigger>
</TabsList>
</div>
<div
className="relative flex max-h-[280px] min-h-[280px] w-full flex-1 flex-col gap-2 overflow-y-auto border-y p-4 pt-2"
id="global-variable-modal-inputs"
>
{error && (
<div className="absolute right-4 top-2.5 text-xs font-medium text-red-500">
{error}
</div>
)}
<TabsContent value="JSON">
<div className="flex flex-col gap-2">
<Label className="!text-mmd">Paste in JSON config</Label>
<Textarea
value={jsonValue}
data-testid="json-input"
onChange={(e) => setJsonValue(e.target.value)}
className="min-h-[225px] font-mono text-mmd"
placeholder="Paste in JSON config to add server"
disabled={isPending}
/>
</div>
</TabsContent>
<TabsContent value="STDIO">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label className="flex items-start gap-1 !text-mmd">
Name <span className="text-red-500">*</span>
</Label>
<Input
value={stdioName}
onChange={(e) => setStdioName(e.target.value)}
placeholder="Type server name..."
data-testid="stdio-name-input"
disabled={isPending}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="flex items-start gap-1 !text-mmd">
Command<span className="text-red-500">*</span>
</Label>
<Input
value={stdioCommand}
onChange={(e) => setStdioCommand(e.target.value)}
placeholder="Type command..."
data-testid="stdio-command-input"
disabled={isPending}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="!text-mmd">Arguments</Label>
<InputListComponent
value={stdioArgs}
handleOnNewValue={({ value }) => setStdioArgs(value)}
disabled={isPending}
placeholder="Type argument..."
listAddLabel="Add Argument"
editNode={false}
id="stdio-args"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="!text-mmd">Environment Variables</Label>
<IOKeyPairInput
value={stdioEnv}
onChange={setStdioEnv}
duplicateKey={false}
isList={true}
isInputField={true}
/>
</div>
</div>
</TabsContent>
<TabsContent value="SSE">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label className="flex items-start gap-1 !text-mmd">
Name<span className="text-red-500">*</span>
</Label>
<Input
value={sseName}
onChange={(e) => setSseName(e.target.value)}
placeholder="Name"
disabled={isPending}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="flex items-start gap-1 !text-mmd">
SSE URL<span className="text-red-500">*</span>
</Label>
<Input
value={sseUrl}
onChange={(e) => setSseUrl(e.target.value)}
placeholder="SSE URL"
disabled={isPending}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="!text-mmd">Headers</Label>
<IOKeyPairInput
value={sseHeaders}
onChange={setSseHeaders}
duplicateKey={false}
isList={true}
isInputField={true}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="!text-mmd">Environment Variables</Label>
<IOKeyPairInput
value={sseEnv}
onChange={setSseEnv}
duplicateKey={false}
isList={true}
isInputField={true}
/>
</div>
</div>
</TabsContent>
</div>
</Tabs>
</div>
</div>
<div className="flex justify-end gap-2 p-4">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
<span className="text-mmd font-normal">Cancel</span>
</Button>
<Button
size="sm"
onClick={submitForm}
data-testid="add-mcp-server-button"
loading={isPending}
>
<span className="text-mmd">
{initialData ? "Update Server" : "Add Server"}
</span>
</Button>
</div>
</BaseModal.Content>
</BaseModal>
);
}

View file

@ -20,24 +20,20 @@ export const SearchInput = memo(function SearchInput({
}) {
return (
<div className="relative w-full flex-1">
<ForwardedIconComponent
name="Search"
className="absolute inset-y-0 left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-primary"
/>
<Input
ref={searchInputRef}
type="search"
icon={"Search"}
data-testid="sidebar-search-input"
className="w-full rounded-lg bg-background pl-8 text-sm"
placeholder=""
inputClassName="w-full rounded-lg bg-background text-sm"
placeholder="Search"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onChange={handleInputChange}
value={search}
/>
{!isInputFocused && search === "" && (
<div className="pointer-events-none absolute inset-y-0 left-8 top-1/2 flex w-4/5 -translate-y-1/2 items-center justify-between gap-2 text-sm text-muted-foreground">
Search{" "}
<div className="pointer-events-none absolute inset-y-0 right-3 top-1/2 flex -translate-y-1/2 items-center justify-between gap-2 text-sm text-muted-foreground">
<span>
<ShortcutDisplay sidebar shortcut="/" />
</span>

View file

@ -2,6 +2,7 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { CustomLink } from "@/customization/components/custom-link";
import { ENABLE_LANGFLOW_STORE } from "@/customization/feature-flags";
const SidebarMenuButtons = ({
hasStore = false,
@ -11,7 +12,8 @@ const SidebarMenuButtons = ({
}) => {
return (
<>
{hasStore && (
{/* TODO: Remove this on cleanup */}
{ENABLE_LANGFLOW_STORE && hasStore && (
<SidebarMenuButton asChild>
<CustomLink
to="/store"

View file

@ -151,7 +151,7 @@ const HeaderComponent = ({
data-testid="search-store-input"
type="text"
placeholder={`Search ${flowType}...`}
className="mr-2"
className="mr-2 !text-mmd"
inputClassName="!text-mmd"
value={debouncedSearch}
onChange={handleSearch}

View file

@ -38,7 +38,7 @@ const InputSearchComponent = ({
data-testid="search-store-input"
disabled={loading}
placeholder={getSearchPlaceholder()}
className="absolute h-12 pl-5 pr-12"
className="h-12 pl-5 pr-12"
onChange={onChange}
onKeyDown={onKeyDown}
value={value}

View file

@ -4,7 +4,10 @@ import { IS_MAC } from "@/constants/constants";
import { useGetFolderQuery } from "@/controllers/API/queries/folders/use-get-folder";
import { CustomBanner } from "@/customization/components/custom-banner";
import { CustomMcpServerTab } from "@/customization/components/custom-McpServerTab";
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
import {
ENABLE_DATASTAX_LANGFLOW,
ENABLE_MCP,
} from "@/customization/feature-flags";
import useFlowsManagerStore from "@/stores/flowsManagerStore";
import { useFolderStore } from "@/stores/foldersStore";
import { FlowType } from "@/types/flow";
@ -77,8 +80,11 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
}, []);
const isEmptyFolder =
flows?.find((flow) => flow.folder_id === (folderId ?? myCollectionId)) ===
undefined;
flows?.find(
(flow) =>
flow.folder_id === (folderId ?? myCollectionId) &&
(ENABLE_MCP ? flow.is_component === false : true),
) === undefined;
const handleFileDrop = useFileDrop(isEmptyFolder ? undefined : flowType);
@ -239,7 +245,7 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
{isEmptyFolder ? (
<EmptyFolder setOpenModal={setNewProjectModal} />
) : (
<div className="">
<div className="flex h-full flex-col">
{isLoading ? (
view === "grid" ? (
<div className="mt-4 grid grid-cols-1 gap-1 md:grid-cols-2 lg:grid-cols-3">
@ -287,7 +293,7 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
</div>
)
) : flowType === "flows" ? (
<div className="pt-2 text-center text-sm text-secondary-foreground">
<div className="pt-24 text-center text-sm text-secondary-foreground">
No flows in this project.{" "}
<a
onClick={() => setNewProjectModal(true)}
@ -298,7 +304,7 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
, or browse the store.
</div>
) : (
<div className="pt-2 text-center text-sm text-secondary-foreground">
<div className="pt-24 text-center text-sm text-secondary-foreground">
No saved or custom components. Learn more about{" "}
<a
href="https://docs.langflow.org/components-custom-components"

View file

@ -3,6 +3,7 @@ import { SidebarProvider } from "@/components/ui/sidebar";
import { CustomStoreSidebar } from "@/customization/components/custom-store-sidebar";
import {
ENABLE_DATASTAX_LANGFLOW,
ENABLE_LANGFLOW_STORE,
ENABLE_PROFILE_ICONS,
} from "@/customization/feature-flags";
import useAuthStore from "@/stores/authStore";
@ -37,6 +38,16 @@ export default function SettingsPage(): JSX.Element {
}
sidebarNavItems.push(
{
title: "MCP Connections",
href: "/settings/mcp-servers",
icon: (
<ForwardedIconComponent
name="Mcp"
className="w-4 flex-shrink-0 justify-start stroke-[1.5]"
/>
),
},
{
title: "Global Variables",
href: "/settings/global-variables",
@ -70,9 +81,9 @@ export default function SettingsPage(): JSX.Element {
},
);
// TODO: Remove this on cleanup
if (!ENABLE_DATASTAX_LANGFLOW) {
const langflowItems = CustomStoreSidebar();
const langflowItems = CustomStoreSidebar(true, ENABLE_LANGFLOW_STORE);
sidebarNavItems.splice(2, 0, ...langflowItems);
}

View file

@ -0,0 +1,172 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Loading from "@/components/ui/loading";
import { useDeleteMCPServer } from "@/controllers/API/queries/mcp/use-delete-mcp-server";
import { useGetMCPServer } from "@/controllers/API/queries/mcp/use-get-mcp-server";
import { useGetMCPServers } from "@/controllers/API/queries/mcp/use-get-mcp-servers";
import AddMcpServerModal from "@/modals/addMcpServerModal";
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
import useAlertStore from "@/stores/alertStore";
import { MCPServerInfoType } from "@/types/mcp";
import { useState } from "react";
export default function MCPServersPage() {
const { data: servers } = useGetMCPServers();
const { mutate: deleteServer } = useDeleteMCPServer();
const setErrorData = useAlertStore((state) => state.setErrorData);
const [addOpen, setAddOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editInitialData, setEditInitialData] = useState<any>(null);
const { mutateAsync: getServer } = useGetMCPServer();
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [serverToDelete, setServerToDelete] =
useState<MCPServerInfoType | null>(null);
const handleEdit = async (name: string) => {
try {
const data = await getServer({ name });
setEditInitialData(data);
setEditOpen(true);
} catch (e: any) {
setErrorData({ title: "Error fetching server", list: [e.message] });
} finally {
}
};
const handleDelete = (server: MCPServerInfoType) => {
deleteServer(
{ name: server.name },
{
onError: (e: any) =>
setErrorData({ title: "Error deleting server", list: [e.message] }),
},
);
};
const openDeleteModal = (server: MCPServerInfoType) => {
setServerToDelete(server);
setDeleteModalOpen(true);
};
return (
<div className="flex h-full w-full flex-col gap-6">
<div className="flex w-full items-start justify-between gap-6">
<div className="flex flex-col">
<h2 className="flex items-center text-lg font-semibold tracking-tight">
MCP Connections
<ForwardedIconComponent
name="Mcp"
className="ml-2 h-5 w-5 text-primary"
/>
</h2>
<p className="text-sm text-muted-foreground">
Manage MCP Servers for use in your flows.
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="primary"
onClick={() => setAddOpen(true)}
data-testid="add-mcp-server-button-page"
>
<ForwardedIconComponent name="Plus" className="w-4" />
Add MCP Server
</Button>
<AddMcpServerModal open={addOpen} setOpen={setAddOpen} />
</div>
</div>
<div className="flex h-full flex-col gap-2">
{servers ? (
<>
{servers.length === 0 ? (
<div className="w-full pt-8 text-center text-sm text-muted-foreground">
No MCP servers added
</div>
) : (
<div className="text-sm font-medium text-muted-foreground">
Servers
</div>
)}
<div className="flex flex-col gap-1">
{servers.map((server) => (
<div
key={server.id}
className="flex items-center justify-between rounded-lg px-3 py-2 shadow-sm transition-colors hover:bg-accent"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{server.name}</span>
<span className="text-mmd text-muted-foreground">
{server.toolsCount} action
{server.toolsCount === 1 ? "" : "s"}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="iconSm"
data-testid={`mcp-server-menu-button-${server.name}`}
className="text-muted-foreground hover:bg-accent"
>
<ForwardedIconComponent
name="Ellipsis"
className="h-5 w-5"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(server.name)}>
<ForwardedIconComponent
name="SquarePen"
className="mr-2 h-4 w-4"
/>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openDeleteModal(server)}
className="text-destructive"
>
<ForwardedIconComponent
name="Trash2"
className="mr-2 h-4 w-4"
/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
{editOpen && (
<AddMcpServerModal
open={editOpen}
setOpen={setEditOpen}
initialData={editInitialData}
/>
)}
<DeleteConfirmationModal
open={deleteModalOpen}
setOpen={setDeleteModalOpen}
onConfirm={() => {
if (serverToDelete) handleDelete(serverToDelete);
setDeleteModalOpen(false);
setServerToDelete(null);
}}
description={"MCP Server"}
/>
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<Loading />
</div>
)}
</div>
</div>
);
}

View file

@ -31,6 +31,7 @@ import SettingsPage from "./pages/SettingsPage";
import ApiKeysPage from "./pages/SettingsPage/pages/ApiKeysPage";
import GeneralPage from "./pages/SettingsPage/pages/GeneralPage";
import GlobalVariablesPage from "./pages/SettingsPage/pages/GlobalVariablesPage";
import MCPServersPage from "./pages/SettingsPage/pages/MCPServersPage";
import MessagesPage from "./pages/SettingsPage/pages/messagesPage";
import ShortcutsPage from "./pages/SettingsPage/pages/ShortcutsPage";
import ViewPage from "./pages/ViewPage";
@ -124,6 +125,7 @@ const router = createBrowserRouter(
path="global-variables"
element={<GlobalVariablesPage />}
/>
<Route path="mcp-servers" element={<MCPServersPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route
path="general/:scrollId?"

View file

@ -1248,7 +1248,7 @@
}
.btn-add-input-list {
@apply flex h-8 w-full items-center justify-center rounded-md p-2 text-sm hover:bg-muted;
@apply flex h-6 w-full items-center justify-center rounded-md p-2 text-sm hover:bg-muted;
}
.btn-playground-actions {

View file

@ -7,3 +7,19 @@ export type MCPSettingsType = {
description?: string;
input_schema?: Record<string, any>;
};
export type MCPServerInfoType = {
id: string;
name: string;
description: string;
toolsCount: number;
};
export type MCPServerType = {
name: string;
command?: string;
url?: string;
args?: string[];
env?: Record<string, string>;
headers?: Record<string, string>;
};

View file

@ -0,0 +1,162 @@
import { MCPServerType } from "@/types/mcp";
/**
* Extracts the first MCP server from a JSON string or object.
* Supports:
* 1. { mcpServers: { ... } }
* 2. { ... } (object with server keys)
* 3. a single server object
* Returns: { name, server } or throws an error.
*/
export function extractFirstMcpServerFromJson(json: string | object): {
name: string;
server: Omit<MCPServerType, "name">;
} {
let parsed: any = json;
if (typeof json === "string") {
try {
parsed = JSON.parse(json);
} catch (e) {
throw new Error("Invalid JSON format.");
}
}
let serverEntries: [string, Omit<MCPServerType, "name">][] = [];
// Case 1: { mcpServers: { ... } }
if (
parsed &&
typeof parsed === "object" &&
parsed.mcpServers &&
typeof parsed.mcpServers === "object"
) {
serverEntries = Object.entries(parsed.mcpServers) as [
string,
Omit<MCPServerType, "name">,
][];
}
// Case 2: { ... } (object with server keys)
else if (
parsed &&
typeof parsed === "object" &&
Object.values(parsed).some(
(v) =>
v &&
typeof v === "object" &&
"command" in v &&
Array.isArray((v as any).args),
)
) {
serverEntries = Object.entries(parsed).filter(
([, v]) =>
v &&
typeof v === "object" &&
"command" in v &&
Array.isArray((v as any).args),
) as [string, Omit<MCPServerType, "name">][];
}
// Case 3: single server object
else if (
parsed &&
typeof parsed === "object" &&
"command" in parsed &&
Array.isArray((parsed as any).args)
) {
serverEntries = [["server", parsed]];
}
if (serverEntries.length === 0) {
throw new Error("No valid MCP server found in the input.");
}
const [name, server] = serverEntries[0];
if (!server.command || !Array.isArray(server.args)) {
throw new Error(
"Each MCP server must have a 'command' and an 'args' array.",
);
}
return { name, server };
}
/**
* Extracts all MCP servers from a JSON string or object.
* Supports:
* 1. { mcpServers: { ... } }
* 2. { ... } (object with server keys)
* 3. a single server object
* Returns: Array<MCPServerType> or throws an error.
*/
export function extractMcpServersFromJson(
json: string | object,
): MCPServerType[] {
let parsed: any = json;
if (typeof json === "string") {
try {
parsed = JSON.parse(json);
} catch (e) {
try {
parsed = JSON.parse(`{${json}}`);
} catch (e) {
throw new Error("Invalid JSON format.");
}
}
}
let serverEntries: [string, any][] = [];
// Case 1: { mcpServers: { ... } }
if (
parsed &&
typeof parsed === "object" &&
parsed.mcpServers &&
typeof parsed.mcpServers === "object"
) {
serverEntries = Object.entries(parsed.mcpServers);
}
// Case 2: { ... } (object with server keys)
else if (
parsed &&
typeof parsed === "object" &&
Object.values(parsed).some(
(v) =>
v &&
typeof v === "object" &&
"command" in v &&
Array.isArray((v as any).args),
)
) {
serverEntries = Object.entries(parsed).filter(
([, v]) =>
v &&
typeof v === "object" &&
"command" in v &&
Array.isArray((v as any).args),
);
}
// Case 3: single server object
else if (
parsed &&
typeof parsed === "object" &&
"command" in parsed &&
Array.isArray((parsed as any).args)
) {
serverEntries = [["server", parsed]];
}
if (serverEntries.length === 0) {
throw new Error("No valid MCP server found in the input.");
}
// Validate and map all servers
const validServers = serverEntries.filter(
([, server]) => server.command && Array.isArray(server.args),
);
if (validServers.length === 0) {
throw new Error("No valid MCP server found in the input.");
}
return validServers.map(([name, server]) => ({
name: name.slice(0, 20),
command: server.command,
args: server.args,
env: server.env && typeof server.env === "object" ? server.env : {},
url: server.url,
}));
}

View file

@ -478,6 +478,7 @@ const config = {
acc[`.truncate-${className}`] = {
position: "relative",
overflow: "hidden",
pointerEvents: "none",
"&::after": {
content: '""',
position: "absolute",
@ -491,6 +492,7 @@ const config = {
acc[`.truncate-${className}`] = {
position: "relative",
overflow: "hidden",
pointerEvents: "none",
"&::after": {
content: '""',
position: "absolute",

View file

@ -199,7 +199,6 @@ test(
// Create a new flow with MCP component
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("mcp connection");
@ -214,57 +213,54 @@ test(
});
await page.getByTestId("fit_view").click();
await zoomOut(page, 3);
// Switch to SSE tab and paste the URL
await page.getByTestId("tab_1_sse").click();
await expect(page.getByTestId("dropdown_str_tool")).toBeHidden();
await page.waitForSelector('[data-testid="textarea_str_sse_url"]', {
try {
await page.getByText("Add MCP Server", { exact: true }).click({
timeout: 5000,
});
} catch (error) {
await page
.getByTestId("mcp-server-dropdown")
.click({ timeout: 3000 });
await page.getByText("Add MCP Server", { exact: true }).click({
timeout: 5000,
});
}
await page.waitForSelector('[data-testid="add-mcp-server-button"]', {
state: "visible",
timeout: 30000,
});
await page.waitForTimeout(2000);
await page.getByTestId("textarea_str_sse_url").fill("");
await page.getByTestId("textarea_str_sse_url").fill(sseUrl);
await page.waitForSelector('[data-testid="json-input"]', {
state: "visible",
timeout: 30000,
});
await page.waitForTimeout(2000);
await page.getByTestId("json-input").fill(configJsonLinux || "");
// Wait for the tools to become available
let attempts = 0;
const maxAttempts = 3;
let dropdownEnabled = false;
await page.getByTestId("add-mcp-server-button").click();
while (attempts < maxAttempts && !dropdownEnabled) {
await page.getByTestId("refresh-button-sse_url").click();
await expect(page.getByTestId("dropdown_str_tool")).toBeVisible({
timeout: 30000,
});
try {
await page.waitForSelector(
'[data-testid="dropdown_str_tool"]:not([disabled])',
{
timeout: 10000,
state: "visible",
},
);
dropdownEnabled = true;
} catch (error) {
attempts++;
console.log(`Retry attempt ${attempts} for refresh button`);
}
}
await page.waitForSelector(
'[data-testid="dropdown_str_tool"]:not([disabled])',
{
timeout: 10000,
state: "visible",
},
);
if (!dropdownEnabled) {
throw new Error(
"Dropdown did not become enabled after multiple refresh attempts",
);
}
// Verify tools are available
await page.waitForTimeout(3000);
await page.getByTestId("dropdown_str_tool").click();
await page.waitForTimeout(3000);
const fetchOptionCount = await page.getByText("mcp_test_name").count();
expect(fetchOptionCount).toBeGreaterThan(0);
// If we get here, the test passed

View file

@ -29,36 +29,48 @@ test(
await zoomOut(page, 3);
await page.getByTestId("dropdown_str_tool").isDisabled();
await expect(page.getByTestId("dropdown_str_tool")).toBeHidden();
let attempts = 0;
const maxAttempts = 3;
let dropdownEnabled = false;
while (attempts < maxAttempts && !dropdownEnabled) {
await page.getByTestId("refresh-button-command").click();
await page.waitForTimeout(3000);
try {
await page.waitForSelector(
'[data-testid="dropdown_str_tool"]:not([disabled])',
{
timeout: 10000,
state: "visible",
},
);
dropdownEnabled = true;
} catch (error) {
attempts++;
console.log(`Retry attempt ${attempts} for refresh button`);
}
try {
await page.getByText("Add MCP Server", { exact: true }).click({
timeout: 5000,
});
} catch (error) {
await page.getByTestId("mcp-server-dropdown").click({ timeout: 3000 });
await page.getByText("Add MCP Server", { exact: true }).click({
timeout: 5000,
});
}
if (!dropdownEnabled) {
throw new Error(
"Dropdown did not become enabled after multiple refresh attempts",
);
}
await page.waitForSelector('[data-testid="add-mcp-server-button"]', {
state: "visible",
timeout: 30000,
});
await page.getByTestId("stdio-tab").click();
await page.waitForSelector('[data-testid="stdio-name-input"]', {
state: "visible",
timeout: 30000,
});
await page.getByTestId("stdio-name-input").fill("test server");
await page.getByTestId("stdio-command-input").fill("uvx mcp-server-fetch");
await page.getByTestId("add-mcp-server-button").click();
await expect(page.getByTestId("dropdown_str_tool")).toBeVisible({
timeout: 30000,
});
await page.waitForSelector(
'[data-testid="dropdown_str_tool"]:not([disabled])',
{
timeout: 10000,
state: "visible",
},
);
await page.getByTestId("dropdown_str_tool").click();
@ -89,124 +101,86 @@ test(
expect(urlOptionCount).toBeGreaterThan(0);
await page.getByTestId("tab_1_sse").click();
await page.getByTestId("user_menu_button").click({ timeout: 3000 });
await page.waitForSelector('[data-testid="textarea_str_sse_url"]', {
await page.getByTestId("menu_settings_button").click({ timeout: 3000 });
await page.waitForSelector('[data-testid="sidebar-nav-MCP Connections"]', {
timeout: 30000,
});
await page
.getByTestId("sidebar-nav-MCP Connections")
.click({ timeout: 3000 });
await page.waitForSelector('[data-testid="add-mcp-server-button-page"]', {
timeout: 3000,
});
await expect(page.getByText("test_server")).toBeVisible({
timeout: 3000,
});
await page
.getByTestId(`mcp-server-menu-button-test_server`)
.click({ timeout: 3000 });
await page
.getByText("Edit", { exact: true })
.first()
.click({ timeout: 3000 });
await page.waitForSelector('[data-testid="add-mcp-server-button"]', {
state: "visible",
timeout: 30000,
});
let sseURLCount = await page.getByTestId("textarea_str_sse_url").count();
expect(sseURLCount).toBeGreaterThan(0);
await page.waitForSelector('[data-testid="dropdown_str_tool"]:disabled', {
timeout: 30000,
await expect(page.getByTestId("json-tab")).toBeDisabled({
timeout: 3000,
});
attempts = 0;
dropdownEnabled = false;
await page.waitForTimeout(1000);
while (attempts < maxAttempts && !dropdownEnabled) {
await page.waitForTimeout(3000);
try {
await page.waitForSelector(
'[data-testid="dropdown_str_tool"]:not([disabled])',
{
timeout: 30000,
state: "visible",
},
);
dropdownEnabled = true;
} catch (error) {
attempts++;
console.log(`Retry attempt ${attempts} for second refresh button`);
await page.getByTestId("refresh-button-sse_url").click();
}
}
if (!dropdownEnabled) {
throw new Error(
"Dropdown did not become enabled after multiple refresh attempts",
);
}
await page.getByTestId("tab_0_stdio").click();
await page.waitForTimeout(2000);
await page.getByTestId("fit_view").click();
attempts = 0;
dropdownEnabled = false;
while (attempts < maxAttempts && !dropdownEnabled) {
await page.getByTestId("refresh-button-command").click();
await page.waitForTimeout(3000);
try {
await page.waitForSelector(
'[data-testid="dropdown_str_tool"]:not([disabled])',
{
timeout: 10000,
state: "visible",
},
);
dropdownEnabled = true;
} catch (error) {
attempts++;
console.log(`Retry attempt ${attempts} for second refresh button`);
}
}
if (!dropdownEnabled) {
throw new Error(
"Dropdown did not become enabled after multiple refresh attempts",
);
}
await page.getByTestId("dropdown_str_tool").click();
fetchOptionCount = await page.getByTestId("fetch-0-option").count();
await page.getByTestId("fetch-0-option").click();
await page.waitForTimeout(2000);
await page.getByTestId("fit_view").click();
expect(fetchOptionCount).toBeGreaterThan(0);
await page.waitForSelector('[data-testid="int_int_max_length"]', {
state: "visible",
timeout: 30000,
await expect(page.getByTestId("stdio-tab")).not.toBeDisabled({
timeout: 3000,
});
maxLengthOptionCount = await page.getByTestId("int_int_max_length").count();
expect(maxLengthOptionCount).toBeGreaterThan(0);
urlOptionCount = await page
.getByTestId("anchor-popover-anchor-input-url")
.count();
expect(urlOptionCount).toBeGreaterThan(0);
await page.getByTestId("tab_1_sse").click();
await page.waitForSelector('[data-testid="textarea_str_sse_url"]', {
state: "visible",
timeout: 30000,
await expect(page.getByTestId("sse-tab")).toBeDisabled({
timeout: 3000,
});
sseURLCount = await page.getByTestId("textarea_str_sse_url").count();
expect(await page.getByTestId("stdio-command-input").inputValue()).toBe(
"uvx mcp-server-fetch",
);
expect(sseURLCount).toBeGreaterThan(0);
await page.getByTestId("add-mcp-server-button").click();
await page.waitForSelector('[data-testid="dropdown_str_tool"]:disabled', {
timeout: 30000,
await page
.getByTestId(`mcp-server-menu-button-test_server`)
.click({ timeout: 3000 });
await page
.getByText("Delete", { exact: true })
.first()
.click({ timeout: 3000 });
await page.waitForSelector(
'[data-testid="btn_delete_delete_confirmation_modal"]',
{
timeout: 3000,
},
);
await page
.getByTestId("btn_delete_delete_confirmation_modal")
.click({ timeout: 3000 });
await page.waitForSelector('[data-testid="add-mcp-server-button-page"]', {
timeout: 3000,
});
await page.waitForTimeout(3000);
await expect(page.getByText("test_server")).not.toBeVisible({
timeout: 3000,
});
},
);