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
This commit is contained in:
parent
043ba55718
commit
4a09655f2f
9 changed files with 282 additions and 83 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { useAddMCPServer } from "@/controllers/API/queries/mcp/use-add-mcp-server";
|
||||
import { useGetMCPServers } from "@/controllers/API/queries/mcp/use-get-mcp-servers";
|
||||
import AddMcpServerModal from "@/modals/addMcpServerModal";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ListSelectionComponent from "../../../../../CustomNodes/GenericNode/components/ListSelectionComponent";
|
||||
import { cn } from "../../../../../utils/utils";
|
||||
|
|
@ -15,37 +17,67 @@ export default function McpComponent({
|
|||
id = "",
|
||||
}: InputProps<string, any>): JSX.Element {
|
||||
const { data: mcpServers } = useGetMCPServers();
|
||||
const { mutate: addMcpServer } = useAddMCPServer();
|
||||
const setErrorData = useAlertStore((state) => state.setErrorData);
|
||||
const options = useMemo(
|
||||
() =>
|
||||
mcpServers?.map((server) => ({
|
||||
name: server.name,
|
||||
description: !server.toolsCount
|
||||
? "No actions found"
|
||||
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`,
|
||||
description:
|
||||
server.toolsCount === null
|
||||
? "Loading..."
|
||||
: !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[]>([]);
|
||||
const { name, config } = useMemo(
|
||||
() => value ?? { name: "", config: {} },
|
||||
[value],
|
||||
);
|
||||
|
||||
// Initialize selected item from value on mount or value/options change
|
||||
const selectedOption = useMemo(
|
||||
() =>
|
||||
name
|
||||
? (options?.find((option) => option.name === name) ?? { name: null })
|
||||
: null,
|
||||
[name, options],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedOption = value
|
||||
? options?.find((option) => option.name === value)
|
||||
if (!options) return;
|
||||
const selectedOption = name
|
||||
? options?.find((option) => option.name === name)
|
||||
: null;
|
||||
setSelectedItem(
|
||||
selectedOption ? [{ name: selectedOption.name }] : [{ name: "" }],
|
||||
);
|
||||
if (value !== selectedOption?.name) {
|
||||
handleOnNewValue({ value: "" }, { skipSnapshot: true });
|
||||
|
||||
if (
|
||||
name !== selectedOption?.name &&
|
||||
Object.keys(config ?? {}).length === 0
|
||||
) {
|
||||
setSelectedItem(
|
||||
selectedOption ? [{ name: selectedOption.name }] : [{ name: "" }],
|
||||
);
|
||||
handleOnNewValue(
|
||||
{ value: { name: "", config: {} } },
|
||||
{ skipSnapshot: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}, [value, options]);
|
||||
setSelectedItem([{ name }]);
|
||||
}, [name, options]);
|
||||
|
||||
// Handle selection from dialog
|
||||
const handleSelection = (item: any) => {
|
||||
setSelectedItem([{ name: item.name }]);
|
||||
handleOnNewValue({ value: item.name }, { skipSnapshot: true });
|
||||
handleOnNewValue(
|
||||
{ value: { name: item.name, config: {} } },
|
||||
{ skipSnapshot: true },
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
|
|
@ -53,49 +85,110 @@ export default function McpComponent({
|
|||
setAddOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenListSelectionDialog = () => setOpen(true);
|
||||
const handleSaveButtonClick = () => {
|
||||
addMcpServer(
|
||||
{
|
||||
name,
|
||||
...(config ?? {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleSuccess(name);
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorData({
|
||||
title: "Error adding MCP server",
|
||||
list: [error.message],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveButtonClick = () => {
|
||||
handleOnNewValue({ value: { name: "", config: {} } });
|
||||
};
|
||||
|
||||
const handleOpenListSelectionDialog = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
const handleCloseListSelectionDialog = () => setOpen(false);
|
||||
|
||||
const handleSuccess = (server: string) => {
|
||||
handleOnNewValue({ value: server });
|
||||
handleOnNewValue({ value: { name: server, config: {} } });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const showSaveButton = useMemo(() => {
|
||||
return (
|
||||
!selectedOption?.name &&
|
||||
Object.keys(config ?? {}).length > 0 &&
|
||||
options !== null
|
||||
);
|
||||
}, [selectedOption, config]);
|
||||
|
||||
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}
|
||||
{options == null || options.length > 0 || showSaveButton ? (
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
variant={!showSaveButton ? "primary" : "secondary"}
|
||||
size="xs"
|
||||
role="combobox"
|
||||
onClick={
|
||||
!showSaveButton
|
||||
? handleOpenListSelectionDialog
|
||||
: handleRemoveButtonClick
|
||||
}
|
||||
className={cn(
|
||||
!showSaveButton
|
||||
? "dropdown-component-outline input-edit-node"
|
||||
: "",
|
||||
"w-full py-2",
|
||||
)}
|
||||
data-testid="mcp-server-dropdown"
|
||||
disabled={disabled || !options}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-start text-sm font-normal",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-start text-sm font-normal",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedItem[0]?.name
|
||||
<span className="truncate">
|
||||
{!options
|
||||
? "Loading servers..."
|
||||
: 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>
|
||||
</span>
|
||||
<ForwardedIconComponent
|
||||
name={!showSaveButton ? "ChevronsUpDown" : "X"}
|
||||
className="ml-auto h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
{showSaveButton && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="iconMd"
|
||||
className="px-2.5"
|
||||
onClick={handleSaveButtonClick}
|
||||
data-testid="save-mcp-server-button"
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name="Save"
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" onClick={handleAddButtonClick}>
|
||||
<span>Add MCP Server</span>
|
||||
</Button>
|
||||
)}
|
||||
{options && (
|
||||
<>
|
||||
<ListSelectionComponent
|
||||
open={open}
|
||||
onClose={handleCloseListSelectionDialog}
|
||||
|
|
@ -105,7 +198,7 @@ export default function McpComponent({
|
|||
options={options}
|
||||
limit={1}
|
||||
id={id}
|
||||
value={value}
|
||||
value={name}
|
||||
editNode={editNode}
|
||||
headerSearchPlaceholder="Search MCP Servers..."
|
||||
handleOnNewValue={handleOnNewValue}
|
||||
|
|
@ -113,13 +206,13 @@ export default function McpComponent({
|
|||
addButtonText="Add MCP Server"
|
||||
onAddButtonClick={handleAddButtonClick}
|
||||
/>
|
||||
<AddMcpServerModal
|
||||
open={addOpen}
|
||||
setOpen={setAddOpen}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AddMcpServerModal
|
||||
open={addOpen}
|
||||
setOpen={setAddOpen}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,52 @@
|
|||
import { useQueryFunctionType } from "@/types/api";
|
||||
import { MCPServerInfoType } from "@/types/mcp";
|
||||
import { useEffect } from "react";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { UseRequestProcessor } from "../../services/request-processor";
|
||||
|
||||
// This type is now updated to allow nulls for mode/toolsCount
|
||||
// type getMCPServersResponse = Array<MCPServerInfoType>;
|
||||
|
||||
type getMCPServersResponse = Array<MCPServerInfoType>;
|
||||
|
||||
export const useGetMCPServers: useQueryFunctionType<
|
||||
undefined,
|
||||
getMCPServersResponse
|
||||
> = (options) => {
|
||||
const { query } = UseRequestProcessor();
|
||||
const { query, queryClient } = UseRequestProcessor();
|
||||
|
||||
// First fetch: action_count=false (fast)
|
||||
const responseFn = async () => {
|
||||
try {
|
||||
const { data } = await api.get<getMCPServersResponse>(
|
||||
`${getURL("MCP_SERVERS", undefined, true)}`,
|
||||
`${getURL("MCP_SERVERS", undefined, true)}?action_count=false`,
|
||||
);
|
||||
// Merge with cached data to preserve non-null mode/toolsCount
|
||||
const cachedData = queryClient.getQueryData(["useGetMCPServers"]) as
|
||||
| getMCPServersResponse
|
||||
| undefined;
|
||||
if (cachedData && Array.isArray(cachedData)) {
|
||||
const merged = data.map((server) => {
|
||||
const cached = cachedData.find((s) => s.name === server.name);
|
||||
return cached && (cached.toolsCount !== null || cached.mode !== null)
|
||||
? { ...server, toolsCount: cached.toolsCount, mode: cached.mode }
|
||||
: server;
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Second fetch: action_count=true (slow, updates mode/toolsCount)
|
||||
const fetchWithCounts = async () => {
|
||||
try {
|
||||
const { data } = await api.get<getMCPServersResponse>(
|
||||
`${getURL("MCP_SERVERS", undefined, true)}?action_count=true`,
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
|
@ -28,5 +59,23 @@ export const useGetMCPServers: useQueryFunctionType<
|
|||
...options,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (queryResult.data && queryResult.data.length > 0) {
|
||||
fetchWithCounts().then((countsData) => {
|
||||
if (!countsData || countsData.length === 0) return;
|
||||
// Merge by name
|
||||
queryClient.setQueryData(
|
||||
["useGetMCPServers"],
|
||||
(oldData: getMCPServersResponse = []) => {
|
||||
return oldData.map((server) => {
|
||||
const updated = countsData.find((s) => s.name === server.name);
|
||||
return updated ? { ...server, ...updated } : server;
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [queryResult.data]);
|
||||
|
||||
return queryResult;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
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";
|
||||
|
|
@ -134,7 +135,7 @@ export default function AddMcpServerModal({
|
|||
"snake_case",
|
||||
"no_blank",
|
||||
"lowercase",
|
||||
]).slice(0, 30);
|
||||
]).slice(0, MAX_MCP_SERVER_NAME_LENGTH);
|
||||
try {
|
||||
await modifyMCPServer({
|
||||
name,
|
||||
|
|
@ -168,7 +169,7 @@ export default function AddMcpServerModal({
|
|||
"snake_case",
|
||||
"no_blank",
|
||||
"lowercase",
|
||||
]).slice(0, 30);
|
||||
]).slice(0, MAX_MCP_SERVER_NAME_LENGTH);
|
||||
try {
|
||||
await modifyMCPServer({
|
||||
name,
|
||||
|
|
@ -202,7 +203,7 @@ export default function AddMcpServerModal({
|
|||
"snake_case",
|
||||
"no_blank",
|
||||
"lowercase",
|
||||
]).slice(0, 30),
|
||||
]).slice(0, MAX_MCP_SERVER_NAME_LENGTH),
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Invalid input");
|
||||
|
|
|
|||
|
|
@ -102,8 +102,9 @@ export default function MCPServersPage() {
|
|||
<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"}
|
||||
{server.toolsCount === null
|
||||
? "Loading..."
|
||||
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ export type MCPSettingsType = {
|
|||
};
|
||||
|
||||
export type MCPServerInfoType = {
|
||||
id: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
toolsCount: number;
|
||||
description?: string;
|
||||
mode: string | null;
|
||||
toolsCount: number | null;
|
||||
};
|
||||
|
||||
export type MCPServerType = {
|
||||
|
|
|
|||
|
|
@ -182,5 +182,27 @@ test(
|
|||
await expect(page.getByText("test_server")).not.toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
await awaitBootstrapTest(page, { skipModal: true });
|
||||
await page.getByText("Untitled document").first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.waitForSelector('[data-testid="save-mcp-server-button"]', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByTestId("save-mcp-server-button").click({ timeout: 10000 });
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByTestId("save-mcp-server-button")).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByTestId("mcp-server-dropdown").click({ timeout: 10000 });
|
||||
await expect(page.getByText("test_server")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue