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:
parent
b378eb81d0
commit
60ccdb500f
56 changed files with 2381 additions and 876 deletions
5
src/frontend/package-lock.json
generated
5
src/frontend/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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?.()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
: "",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -660,6 +660,7 @@ export const LANGFLOW_SUPPORTED_TYPES = new Set([
|
|||
"connect",
|
||||
"auth",
|
||||
"query",
|
||||
"mcp",
|
||||
"tools",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
436
src/frontend/src/modals/addMcpServerModal/index.tsx
Normal file
436
src/frontend/src/modals/addMcpServerModal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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?"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
162
src/frontend/src/utils/mcpUtils.ts
Normal file
162
src/frontend/src/utils/mcpUtils.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue