langflow/src/frontend/src/modals/addMcpServerModal/index.tsx
Lucas Oliveira 4a09655f2f
fix: implemented cached values and temporary MCP servers on MCP component (#8628)
* Added actionCount to fetch only servers without actionCount

* Updated queries and uses to use servers without action data first, and then to fetch them

* removed comment

* updated constants

* Added loading dropdown

* Make options persist

* Implemented new value format for McpComponent and implemented saving and removing temp Mcp Server if config is existent

* Changed value type

* Implemented cache and saving the server config

* Fixed mcp server test

* fix backend formatting

* fixed lint

* Added await

* Fixed save button not appearing when no servers are available

* added condition to only show save button when options is not null
2025-06-23 14:15:22 +00:00

437 lines
15 KiB
TypeScript

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 { MAX_MCP_SERVER_NAME_LENGTH } from "@/constants/constants";
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, MAX_MCP_SERVER_NAME_LENGTH);
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, MAX_MCP_SERVER_NAME_LENGTH);
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, MAX_MCP_SERVER_NAME_LENGTH),
}));
} 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>
);
}