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:
Lucas Oliveira 2025-07-02 12:31:48 -03:00 committed by GitHub
commit 6460e23bfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 798 additions and 316 deletions

View file

@ -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?.();

View file

@ -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>
)}

View file

@ -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"

View file

@ -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<

View file

@ -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);
}}
>

View file

@ -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.");

View file

@ -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);

View file

@ -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}

View file

@ -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>

View file

@ -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;

View file

@ -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 };