fix: make MCP display loading states when loading tools, surface errors on MCP, sanitize MCP names (#8792)
* Catch timeout errors on check server * Make errors propagate from the MCP clients * Apply timeout error handling and made Server change only trigger a loader on the Tools dropdown * Add placeholder to ToolsInput on errors * Updated useEffect to run when nothing is selected * Added timeout handling to mcp component * Added placeholder to tools component * removed unused props * Added timeout handling on loading of tools on config page * Fixed key pair input not working * Set key pair values as empty list * Surface final error from mcp * Removed ID from tool mode turning on * Turn exception on to more places * Fixed cache on mcp component and make tool mode data not reset * Added loading placeholder only if there are no data * Refresh data if placeholder is Loading on tool mode * Show modal if no tools are available * Add useEffect to run handleOnNewValue if placeholder is Loading actions... * Removed checks from toolsTable to run handleOnNewValue * Sanitized MCP name * Updated message * Fixed actions not loading in mcp component * [autofix.ci] apply automated fixes * reuse mcp servers * mypy fixes * fix: update tool reference in MCPToolsComponent to use field_value * Added last_updated to backend * get latest version of node and compare last_updated before returning post template value * assign last updated and only set node class if newTemplate exists * Adds type * Removed timeout from backend to frontend --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: phact <estevezsebastian@gmail.com> Co-authored-by: Edwin Jose <edwin.jose@datastax.com> Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
parent
1930fe0356
commit
6460e23bfb
21 changed files with 798 additions and 316 deletions
|
|
@ -59,14 +59,15 @@ export const mutateTemplate = async (
|
|||
newTemplate.outputs ?? [],
|
||||
);
|
||||
newNode.tool_mode = toolMode ?? node.tool_mode;
|
||||
}
|
||||
try {
|
||||
setNodeClass(newNode);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Node not found") {
|
||||
console.log("Node not found");
|
||||
} else {
|
||||
throw e;
|
||||
newNode.last_updated = newTemplate.last_updated;
|
||||
try {
|
||||
setNodeClass(newNode);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Node not found") {
|
||||
console.log("Node not found");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
callback?.();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default function ToolsComponent({
|
|||
id = "",
|
||||
handleOnNewValue,
|
||||
isAction = false,
|
||||
placeholder,
|
||||
button_description,
|
||||
title,
|
||||
icon,
|
||||
|
|
@ -46,18 +47,17 @@ export default function ToolsComponent({
|
|||
disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{value && (
|
||||
<ToolsModal
|
||||
open={isModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
isAction={isAction}
|
||||
description={description}
|
||||
rows={value}
|
||||
handleOnNewValue={handleOnNewValue}
|
||||
title={title}
|
||||
icon={icon}
|
||||
/>
|
||||
)}
|
||||
<ToolsModal
|
||||
open={isModalOpen}
|
||||
placeholder={placeholder || ""}
|
||||
setOpen={setIsModalOpen}
|
||||
isAction={isAction}
|
||||
description={description}
|
||||
rows={value || []}
|
||||
handleOnNewValue={handleOnNewValue}
|
||||
title={title}
|
||||
icon={icon}
|
||||
/>
|
||||
<div
|
||||
className="relative flex w-full items-center gap-3"
|
||||
data-testid={"div-" + id}
|
||||
|
|
@ -133,7 +133,10 @@ export default function ToolsComponent({
|
|||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<span>
|
||||
{value.length === 0 ? "No actions available" : "Select actions"}
|
||||
{placeholder ||
|
||||
(value.length === 0
|
||||
? "No actions available"
|
||||
: "Select actions")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ export default function McpComponent({
|
|||
description:
|
||||
server.toolsCount === null
|
||||
? server.error
|
||||
? "Error"
|
||||
? server.error.startsWith("Timeout")
|
||||
? "Timeout"
|
||||
: "Error"
|
||||
: "Loading..."
|
||||
: !server.toolsCount
|
||||
? "No actions found"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import useFlowStore from "@/stores/flowStore";
|
||||
import {
|
||||
APIClassType,
|
||||
ResponseErrorDetailAPI,
|
||||
|
|
@ -26,6 +27,7 @@ export const usePostTemplateValue: useMutationFunctionType<
|
|||
ResponseErrorDetailAPI
|
||||
> = ({ parameterId, nodeId, node }, options?) => {
|
||||
const { mutate } = UseRequestProcessor();
|
||||
const getNode = useFlowStore((state) => state.getNode);
|
||||
|
||||
const postTemplateValueFn = async (
|
||||
payload: IPostTemplateValue,
|
||||
|
|
@ -33,6 +35,7 @@ export const usePostTemplateValue: useMutationFunctionType<
|
|||
const template = node.template;
|
||||
|
||||
if (!template) return;
|
||||
const lastUpdated = new Date().toISOString();
|
||||
const response = await api.post<APIClassType>(
|
||||
getURL("CUSTOM_COMPONENT", { update: "update" }),
|
||||
{
|
||||
|
|
@ -43,8 +46,19 @@ export const usePostTemplateValue: useMutationFunctionType<
|
|||
tool_mode: payload.tool_mode,
|
||||
},
|
||||
);
|
||||
const newTemplate = response.data;
|
||||
newTemplate.last_updated = lastUpdated;
|
||||
const newNode = getNode(nodeId)?.data?.node as APIClassType | undefined;
|
||||
|
||||
return response.data;
|
||||
if (
|
||||
!newNode?.last_updated ||
|
||||
!newTemplate.last_updated ||
|
||||
Date.parse(newNode.last_updated) < Date.parse(newTemplate.last_updated)
|
||||
) {
|
||||
return newTemplate;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const mutation: UseMutationResult<
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import _ from "lodash";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import IconComponent from "../../../../../components/common/genericIconComponent";
|
||||
import { Input } from "../../../../../components/ui/input";
|
||||
import { classNames } from "../../../../../utils/utils";
|
||||
|
|
@ -23,27 +23,38 @@ const IOKeyPairInput = ({
|
|||
return Array.isArray(value) ? value : [value];
|
||||
};
|
||||
|
||||
const ref = useRef<any>([]);
|
||||
ref.current =
|
||||
!value || value?.length === 0 ? [{ "": "" }] : checkValueType(value);
|
||||
const [currentData, setCurrentData] = useState<any[]>(() => {
|
||||
return !value || value?.length === 0 ? [{ "": "" }] : checkValueType(value);
|
||||
});
|
||||
|
||||
// Update internal state when external value changes
|
||||
useEffect(() => {
|
||||
const newData =
|
||||
!value || value?.length === 0 ? [{ "": "" }] : checkValueType(value);
|
||||
setCurrentData(newData);
|
||||
}, [value]);
|
||||
|
||||
const handleChangeKey = (event, idx) => {
|
||||
const oldKey = Object.keys(ref.current[idx])[0];
|
||||
const updatedObj = { [event.target.value]: ref.current[idx][oldKey] };
|
||||
ref.current[idx] = updatedObj;
|
||||
onChange(ref.current);
|
||||
const oldKey = Object.keys(currentData[idx])[0];
|
||||
const updatedObj = { [event.target.value]: currentData[idx][oldKey] };
|
||||
const newData = [...currentData];
|
||||
newData[idx] = updatedObj;
|
||||
setCurrentData(newData);
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
const handleChangeValue = (newValue, idx) => {
|
||||
const key = Object.keys(ref.current[idx])[0];
|
||||
ref.current[idx][key] = newValue;
|
||||
onChange(ref.current);
|
||||
const key = Object.keys(currentData[idx])[0];
|
||||
const newData = [...currentData];
|
||||
newData[idx] = { ...newData[idx], [key]: newValue };
|
||||
setCurrentData(newData);
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames("flex h-full flex-col gap-3")}>
|
||||
{ref.current?.map((obj, index) => {
|
||||
{currentData?.map((obj, index) => {
|
||||
return Object.keys(obj).map((key, idx) => {
|
||||
return (
|
||||
<div key={idx} className="flex w-full gap-2">
|
||||
|
|
@ -66,12 +77,13 @@ const IOKeyPairInput = ({
|
|||
disabled={!isInputField}
|
||||
/>
|
||||
|
||||
{isList && isInputField && index === ref.current.length - 1 ? (
|
||||
{isList && isInputField && index === currentData.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
let newInputList = _.cloneDeep(ref.current);
|
||||
let newInputList = _.cloneDeep(currentData);
|
||||
newInputList.push({ "": "" });
|
||||
setCurrentData(newInputList);
|
||||
onChange(newInputList);
|
||||
}}
|
||||
>
|
||||
|
|
@ -84,8 +96,9 @@ const IOKeyPairInput = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
let newInputList = _.cloneDeep(ref.current);
|
||||
let newInputList = _.cloneDeep(currentData);
|
||||
newInputList.splice(index, 1);
|
||||
setCurrentData(newInputList);
|
||||
onChange(newInputList);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@ export default function AddMcpServerModal({
|
|||
setStdioName("");
|
||||
setStdioCommand("");
|
||||
setStdioArgs([""]);
|
||||
setStdioEnv([{ "": "" }]);
|
||||
setStdioEnv([]);
|
||||
setSseName("");
|
||||
setSseUrl("");
|
||||
setSseEnv([{ "": "" }]);
|
||||
setSseHeaders([{ "": "" }]);
|
||||
setSseEnv([]);
|
||||
setSseHeaders([]);
|
||||
};
|
||||
|
||||
// STDIO state
|
||||
|
|
@ -82,17 +82,13 @@ export default function AddMcpServerModal({
|
|||
const [stdioArgs, setStdioArgs] = useState<string[]>(
|
||||
initialData?.args || [""],
|
||||
);
|
||||
const [stdioEnv, setStdioEnv] = useState<any>(
|
||||
initialData?.env || [{ "": "" }],
|
||||
);
|
||||
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 || [{ "": "" }],
|
||||
);
|
||||
const [sseEnv, setSseEnv] = useState<any>(initialData?.env || []);
|
||||
const [sseHeaders, setSseHeaders] = useState<any>(initialData?.headers || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -102,11 +98,11 @@ export default function AddMcpServerModal({
|
|||
setStdioName(initialData?.name || "");
|
||||
setStdioCommand(initialData?.command || "");
|
||||
setStdioArgs(initialData?.args || [""]);
|
||||
setStdioEnv(initialData?.env || [{ "": "" }]);
|
||||
setStdioEnv(initialData?.env || []);
|
||||
setSseName(initialData?.name || "");
|
||||
setSseUrl(initialData?.url || "");
|
||||
setSseEnv(initialData?.env || [{ "": "" }]);
|
||||
setSseHeaders(initialData?.headers || [{ "": "" }]);
|
||||
setSseEnv(initialData?.env || []);
|
||||
setSseHeaders(initialData?.headers || []);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -153,7 +149,7 @@ export default function AddMcpServerModal({
|
|||
setStdioName("");
|
||||
setStdioCommand("");
|
||||
setStdioArgs([""]);
|
||||
setStdioEnv([{ "": "" }]);
|
||||
setStdioEnv([]);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add MCP server.");
|
||||
|
|
@ -186,8 +182,8 @@ export default function AddMcpServerModal({
|
|||
setOpen(false);
|
||||
setSseName("");
|
||||
setSseUrl("");
|
||||
setSseEnv([{ "": "" }]);
|
||||
setSseHeaders([{ "": "" }]);
|
||||
setSseEnv([]);
|
||||
setSseHeaders([]);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add MCP server.");
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ import {
|
|||
} from "@/components/ui/sidebar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
|
||||
import { APITemplateType } from "@/types/api";
|
||||
import { parseString } from "@/utils/stringManipulation";
|
||||
import { parseString, sanitizeMcpName } from "@/utils/stringManipulation";
|
||||
import { ColDef } from "ag-grid-community";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { cloneDeep } from "lodash";
|
||||
|
|
@ -25,6 +24,7 @@ export default function ToolsTable({
|
|||
data,
|
||||
setData,
|
||||
isAction,
|
||||
placeholder,
|
||||
open,
|
||||
handleOnNewValue,
|
||||
}: {
|
||||
|
|
@ -34,6 +34,7 @@ export default function ToolsTable({
|
|||
open: boolean;
|
||||
handleOnNewValue: handleOnNewValueType;
|
||||
isAction: boolean;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRows, setSelectedRows] = useState<any[] | null>(null);
|
||||
|
|
@ -77,7 +78,7 @@ export default function ToolsTable({
|
|||
}, [agGrid.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && selectedRows) {
|
||||
if (!open) {
|
||||
handleOnNewValue({
|
||||
value: data.map((row) => {
|
||||
const name = parseString(row.name, [
|
||||
|
|
@ -94,7 +95,7 @@ export default function ToolsTable({
|
|||
name !== "" && name !== display_name
|
||||
? name
|
||||
: isAction
|
||||
? ""
|
||||
? sanitizeMcpName(display_name || row.name, 46)
|
||||
: display_name
|
||||
).slice(0, 46);
|
||||
|
||||
|
|
@ -172,11 +173,7 @@ export default function ToolsTable({
|
|||
"uppercase",
|
||||
])
|
||||
: isAction
|
||||
? parseString(params.data.display_name, [
|
||||
"snake_case",
|
||||
"no_blank",
|
||||
"uppercase",
|
||||
])
|
||||
? sanitizeMcpName(params.data.display_name, 46).toUpperCase()
|
||||
: parseString(params.data.tags.join(", "), [
|
||||
"snake_case",
|
||||
"uppercase",
|
||||
|
|
@ -237,8 +234,10 @@ export default function ToolsTable({
|
|||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setSidebarName(e.target.value);
|
||||
handleSidebarInputChange("name", e.target.value);
|
||||
const rawValue = e.target.value;
|
||||
const sanitizedValue = isAction ? sanitizeMcpName(rawValue, 46) : rawValue;
|
||||
setSidebarName(sanitizedValue);
|
||||
handleSidebarInputChange("name", sanitizedValue);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e) => setSearchQuery(e.target.value);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
|
||||
import { APITemplateType } from "@/types/api";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { ForwardedRef, forwardRef, useState } from "react";
|
||||
import { ForwardedRef, forwardRef, useEffect, useState } from "react";
|
||||
import BaseModal from "../baseModal";
|
||||
import ToolsTable from "./components/toolsTable";
|
||||
|
||||
|
|
@ -18,6 +17,7 @@ interface ToolsModalProps {
|
|||
description: string;
|
||||
status: boolean;
|
||||
}[];
|
||||
placeholder: string;
|
||||
handleOnNewValue: handleOnNewValueType;
|
||||
title: string;
|
||||
icon?: string;
|
||||
|
|
@ -29,13 +29,13 @@ const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
|
|||
{
|
||||
description,
|
||||
rows,
|
||||
placeholder,
|
||||
handleOnNewValue,
|
||||
title,
|
||||
icon,
|
||||
open,
|
||||
isAction = false,
|
||||
setOpen,
|
||||
...props
|
||||
}: ToolsModalProps,
|
||||
ref: ForwardedRef<AgGridReact>,
|
||||
) => {
|
||||
|
|
@ -47,6 +47,14 @@ const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
|
|||
|
||||
const [data, setData] = useState<any[]>(cloneDeep(rows));
|
||||
|
||||
useEffect(() => {
|
||||
if (placeholder === "Loading actions...") {
|
||||
handleOnNewValue({
|
||||
value: [],
|
||||
});
|
||||
}
|
||||
}, [placeholder]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
open={open}
|
||||
|
|
@ -70,6 +78,7 @@ const ToolsModal = forwardRef<AgGridReact, ToolsModalProps>(
|
|||
<ToolsTable
|
||||
rows={rows}
|
||||
isAction={isAction}
|
||||
placeholder={placeholder}
|
||||
data={data}
|
||||
setData={setData}
|
||||
open={open}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,9 @@ export default function MCPServersPage() {
|
|||
>
|
||||
{server.toolsCount === null
|
||||
? server.error
|
||||
? "Error"
|
||||
? server.error.startsWith("Timeout")
|
||||
? "Timeout"
|
||||
: "Error"
|
||||
: "Loading..."
|
||||
: `${server.toolsCount} action${server.toolsCount === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export type APIClassType = {
|
|||
field_order?: string[];
|
||||
tool_mode?: boolean;
|
||||
type?: string;
|
||||
last_updated?: string;
|
||||
[key: string]:
|
||||
| Array<string>
|
||||
| string
|
||||
|
|
@ -329,7 +330,8 @@ export type FieldParserType =
|
|||
| "no_blank"
|
||||
| "valid_csv"
|
||||
| "space_case"
|
||||
| "commands";
|
||||
| "commands"
|
||||
| "sanitize_mcp_name";
|
||||
|
||||
export type TableOptionsTypeAPI = {
|
||||
block_add?: boolean;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,52 @@ function validCommands(str: string): string {
|
|||
.join(", ");
|
||||
}
|
||||
|
||||
function sanitizeMcpName(str: string, maxLength: number = 46): string {
|
||||
if (!str || !str.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let name = str;
|
||||
|
||||
// Remove emojis using standard regex patterns (without unicode flags)
|
||||
// This covers most common emoji ranges using surrogate pairs
|
||||
name = name.replace(/[\uD83C-\uDBFF][\uDC00-\uDFFF]/g, ""); // Most emojis
|
||||
name = name.replace(/[\u2600-\u27BF]/g, ""); // Misc symbols and dingbats
|
||||
name = name.replace(/[\uFE00-\uFE0F]/g, ""); // Variation selectors
|
||||
name = name.replace(/\u200D/g, ""); // Zero width joiner
|
||||
|
||||
// Normalize unicode characters to remove diacritics
|
||||
name = name.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
|
||||
// Replace spaces and special characters with underscores
|
||||
name = name.replace(/[^\w\s-]/g, ""); // Keep only word chars, spaces, and hyphens
|
||||
name = name.replace(/[-\s]+/g, "_"); // Replace spaces and hyphens with underscores
|
||||
name = name.replace(/_+/g, "_"); // Collapse multiple underscores
|
||||
|
||||
// Remove leading/trailing underscores
|
||||
name = name.replace(/^_+|_+$/g, "");
|
||||
|
||||
// Ensure it starts with a letter or underscore (not a number)
|
||||
if (name && /^\d/.test(name)) {
|
||||
name = `_${name}`;
|
||||
}
|
||||
|
||||
// Convert to lowercase
|
||||
name = name.toLowerCase();
|
||||
|
||||
// Truncate to max length
|
||||
if (name.length > maxLength) {
|
||||
name = name.substring(0, maxLength).replace(/_+$/, "");
|
||||
}
|
||||
|
||||
// If empty after sanitization, provide a default
|
||||
if (!name) {
|
||||
name = "unnamed";
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
export function parseString(
|
||||
str: string,
|
||||
parsers: FieldParserType[] | FieldParserType,
|
||||
|
|
@ -127,6 +173,9 @@ export function parseString(
|
|||
case "commands":
|
||||
result = validCommands(result);
|
||||
break;
|
||||
case "sanitize_mcp_name":
|
||||
result = sanitizeMcpName(result);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error in parser ${parser}`);
|
||||
|
|
@ -176,3 +225,5 @@ export const convertStringToHTML = (htmlString: string): JSX.Element => {
|
|||
export const sanitizeHTML = (htmlString: string): string => {
|
||||
return DOMPurify.sanitize(htmlString);
|
||||
};
|
||||
|
||||
export { sanitizeMcpName };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue