Skip to content

Commit 82a79aa

Browse files
authored
fix(mcp): stabilize Tool Mode activation (#14188)
* fix(mcp): stabilize tool mode activation * fix(frontend): prevent stale tool mode refresh
1 parent f685d29 commit 82a79aa

7 files changed

Lines changed: 201 additions & 13 deletions

File tree

src/backend/tests/unit/components/models_and_agents/test_mcp_component_dynamic.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
from lfx.components.models_and_agents.mcp_component import MCPToolsComponent
2323

2424

25+
def test_tool_mode_capability_is_available_before_server_discovery() -> None:
26+
"""The toolbar must not wait for an MCP connection to discover Tool Mode."""
27+
tool_placeholder = next(input_ for input_ in MCPToolsComponent.inputs if input_.name == "tool_placeholder")
28+
29+
assert tool_placeholder.tool_mode is True
30+
31+
2532
def _make_tool(name: str) -> MagicMock:
2633
tool = MagicMock()
2734
tool.name = name
@@ -324,6 +331,14 @@ def _build_config(**overrides):
324331
config.update(overrides)
325332
return config
326333

334+
@pytest.mark.asyncio
335+
async def test_clearing_server_keeps_tool_mode_capability(self) -> None:
336+
component = MCPToolsComponent()
337+
338+
build_config = await component.update_build_config(self._build_config(), {}, "mcp_server")
339+
340+
assert build_config["tool_placeholder"]["tool_mode"] is True
341+
327342
@pytest.mark.asyncio
328343
async def test_refresh_bypasses_existing_options(self) -> None:
329344
component = MCPToolsComponent()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { APIClassType } from "@/types/api";
2+
import { mutateTemplate } from "../mutate-template";
3+
4+
describe("mutateTemplate", () => {
5+
afterEach(() => {
6+
jest.useRealTimers();
7+
});
8+
9+
it("sends Tool Mode changes immediately", async () => {
10+
const node = {
11+
template: {
12+
code: { value: "component source" },
13+
},
14+
outputs: [],
15+
tool_mode: false,
16+
} as unknown as APIClassType;
17+
const updatedNode = {
18+
template: node.template,
19+
outputs: [],
20+
last_updated: "2026-07-21T16:30:00.000Z",
21+
} as unknown as APIClassType;
22+
const mutateAsync = jest.fn().mockResolvedValue(updatedNode);
23+
const setNodeClass = jest.fn();
24+
25+
await mutateTemplate(
26+
true,
27+
"mcp-tools-node",
28+
node,
29+
setNodeClass,
30+
{ mutateAsync } as never,
31+
jest.fn(),
32+
"tool_mode",
33+
jest.fn(),
34+
true,
35+
);
36+
37+
expect(mutateAsync).toHaveBeenCalledWith(
38+
expect.objectContaining({ value: true, tool_mode: true }),
39+
);
40+
expect(setNodeClass).toHaveBeenCalledWith(
41+
expect.objectContaining({ tool_mode: true }),
42+
);
43+
});
44+
45+
it("cancels a stale Toolset metadata refresh when Tool Mode changes", async () => {
46+
jest.useFakeTimers();
47+
const node = {
48+
template: {
49+
code: { value: "component source" },
50+
tools_metadata: { value: [{ name: "fetch_content" }] },
51+
},
52+
outputs: [{ name: "component_as_tool" }],
53+
tool_mode: true,
54+
} as unknown as APIClassType;
55+
const metadataMutateAsync = jest.fn();
56+
const toolModeMutateAsync = jest.fn().mockResolvedValue({
57+
template: node.template,
58+
outputs: [],
59+
last_updated: "2026-07-21T16:31:00.000Z",
60+
});
61+
62+
await mutateTemplate(
63+
node.template.tools_metadata.value,
64+
"url-node",
65+
node,
66+
jest.fn(),
67+
{ mutateAsync: metadataMutateAsync } as never,
68+
jest.fn(),
69+
"tools_metadata",
70+
jest.fn(),
71+
true,
72+
);
73+
74+
await mutateTemplate(
75+
false,
76+
"url-node",
77+
node,
78+
jest.fn(),
79+
{ mutateAsync: toolModeMutateAsync } as never,
80+
jest.fn(),
81+
"tool_mode",
82+
jest.fn(),
83+
false,
84+
);
85+
await jest.runAllTimersAsync();
86+
87+
expect(toolModeMutateAsync).toHaveBeenCalledWith(
88+
expect.objectContaining({ value: false, tool_mode: false }),
89+
);
90+
expect(metadataMutateAsync).not.toHaveBeenCalled();
91+
});
92+
});

src/frontend/src/CustomNodes/helpers/mutate-template.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { UseMutationResult } from "@tanstack/react-query";
22
import { cloneDeep, debounce } from "lodash";
33
import { SAVE_DEBOUNCE_TIME } from "@/constants/constants";
4-
import i18n from "../../i18n";
54
import type { APIClassType, ResponseErrorDetailAPI } from "@/types/api";
5+
import i18n from "../../i18n";
66
import { updateHiddenOutputs } from "./update-hidden-outputs";
77

88
// Map to store debounced functions for each node ID + parameter combination
@@ -16,6 +16,7 @@ export const mutateTemplate = async (
1616
postTemplateValue: UseMutationResult<
1717
APIClassType | undefined,
1818
ResponseErrorDetailAPI,
19+
// biome-ignore lint/suspicious/noExplicitAny: legacy mutation payload
1920
any
2021
>,
2122
setErrorData,
@@ -38,6 +39,7 @@ export const mutateTemplate = async (
3839
postTemplateValue: UseMutationResult<
3940
APIClassType | undefined,
4041
ResponseErrorDetailAPI,
42+
// biome-ignore lint/suspicious/noExplicitAny: legacy mutation payload
4143
any
4244
>,
4345
setErrorData,
@@ -89,7 +91,17 @@ export const mutateTemplate = async (
8991
);
9092
}
9193

92-
debouncedFunctions.get(debounceKey)?.(
94+
// Enabling Tool Mode mounts the tools_metadata field, which queues its own
95+
// debounced refresh. If the user turns Tool Mode off before that refresh
96+
// runs, the queued request still carries tool_mode=true and can restore the
97+
// Toolset output after the off response. The explicit toggle supersedes that
98+
// pending metadata refresh.
99+
if (parameterName === "tool_mode") {
100+
debouncedFunctions.get(`${nodeId}-tools_metadata`)?.cancel();
101+
}
102+
103+
const debouncedFunction = debouncedFunctions.get(debounceKey);
104+
debouncedFunction?.(
93105
newValue,
94106
node,
95107
setNodeClass,
@@ -100,4 +112,11 @@ export const mutateTemplate = async (
100112
toolMode,
101113
isRefresh,
102114
);
115+
116+
// Tool Mode is a discrete toggle, so delaying it like a text input leaves
117+
// the node in its previous output shape and gives slower refresh responses
118+
// a chance to repaint the toggle with stale state.
119+
if (parameterName === "tool_mode") {
120+
await debouncedFunction?.flush();
121+
}
103122
};

src/frontend/src/pages/FlowPage/components/nodeToolbarComponent/__tests__/config-transition.test.tsx

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { act, render, screen } from "@testing-library/react";
1+
import { act, fireEvent, render, screen } from "@testing-library/react";
22
import { useUtilityStore } from "@/stores/utilityStore";
33
import NodeToolbarComponent from "../index";
44

55
const mockFreezeAllVertices = jest.fn();
66
const mockAddFlow = jest.fn();
7+
const mockCheckHasToolMode = jest.fn(() => false);
8+
const mockMutateTemplate = jest.fn();
9+
const mockPostToolModeValue = { isPending: false };
710
const mockSetNoticeData = jest.fn();
811
const mockSetErrorData = jest.fn();
912
const mockSetSuccessData = jest.fn();
@@ -12,6 +15,10 @@ jest.mock("@xyflow/react", () => ({
1215
useUpdateNodeInternals: () => jest.fn(),
1316
}));
1417

18+
jest.mock("@/CustomNodes/helpers/mutate-template", () => ({
19+
mutateTemplate: (...args: unknown[]) => mockMutateTemplate(...args),
20+
}));
21+
1522
jest.mock("@/CustomNodes/hooks/use-handle-new-value", () => ({
1623
__esModule: true,
1724
default: () => ({
@@ -59,7 +66,7 @@ jest.mock("@/components/ui/button", () => ({
5966
}));
6067

6168
jest.mock("@/controllers/API/queries/nodes/use-post-template-value", () => ({
62-
usePostTemplateValue: () => ({}),
69+
usePostTemplateValue: () => mockPostToolModeValue,
6370
}));
6471

6572
jest.mock("@/controllers/API/queries/vertex", () => ({
@@ -160,7 +167,7 @@ jest.mock("../../../../../components/ui/select-custom", () => ({
160167
}));
161168

162169
jest.mock("../../../../../utils/reactflowUtils", () => ({
163-
checkHasToolMode: jest.fn(() => false),
170+
checkHasToolMode: () => mockCheckHasToolMode(),
164171
createFlowComponent: jest.fn((data) => ({
165172
name: data.id,
166173
})),
@@ -217,8 +224,16 @@ const getProps = () => ({
217224
node: {
218225
display_name: "Prompt",
219226
description: "Prompt node",
227+
documentation: "",
220228
template: {
221-
code: { value: "print('hello')" },
229+
code: {
230+
value: "print('hello')",
231+
type: "code",
232+
required: true,
233+
list: false,
234+
show: true,
235+
readonly: false,
236+
},
222237
},
223238
outputs: [],
224239
frozen: false,
@@ -237,6 +252,8 @@ const getProps = () => ({
237252
describe("NodeToolbarComponent config transitions", () => {
238253
beforeEach(() => {
239254
jest.clearAllMocks();
255+
mockCheckHasToolMode.mockReturnValue(false);
256+
mockPostToolModeValue.isPending = false;
240257
act(() => {
241258
useUtilityStore.setState({ allowCustomComponents: false });
242259
});
@@ -263,4 +280,40 @@ describe("NodeToolbarComponent config transitions", () => {
263280

264281
expect(screen.queryByTestId("code-button-modal")).not.toBeInTheDocument();
265282
});
283+
284+
it("keeps an optimistic Tool Mode toggle on while its update is pending", () => {
285+
mockCheckHasToolMode.mockReturnValue(true);
286+
const props = {
287+
...getProps(),
288+
data: {
289+
...getProps().data,
290+
node: {
291+
...getProps().data.node,
292+
tool_mode: false,
293+
},
294+
},
295+
};
296+
const { rerender } = render(<NodeToolbarComponent {...props} />);
297+
const toolModeButton = screen.getByTestId("tool-mode-button");
298+
299+
fireEvent.click(screen.getByText("Tool Mode"));
300+
expect(toolModeButton).toHaveClass("text-primary");
301+
302+
mockPostToolModeValue.isPending = true;
303+
rerender(
304+
<NodeToolbarComponent
305+
{...props}
306+
data={{
307+
...props.data,
308+
node: {
309+
...props.data.node,
310+
outputs: [],
311+
tool_mode: false,
312+
},
313+
}}
314+
/>,
315+
);
316+
317+
expect(screen.getByTestId("tool-mode-button")).toHaveClass("text-primary");
318+
});
266319
});

src/frontend/src/pages/FlowPage/components/nodeToolbarComponent/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ const NodeToolbarComponent = memo(
139139
);
140140

141141
useEffect(() => {
142+
// Keep the optimistic toggle state while the server rebuilds the node.
143+
// Other in-flight field refreshes can update the same node first with
144+
// its previous tool_mode value; syncing that transient value makes the
145+
// toggle appear to turn itself off.
146+
if (postToolModeValue.isPending) return;
147+
142148
if (data.node?.tool_mode !== undefined) {
143149
setToolMode(
144150
data.node?.tool_mode ||
@@ -148,7 +154,7 @@ const NodeToolbarComponent = memo(
148154
false,
149155
);
150156
}
151-
}, [data.node?.tool_mode, data.node?.outputs]);
157+
}, [data.node?.tool_mode, data.node?.outputs, postToolModeValue.isPending]);
152158

153159
const { handleNodeClass: handleNodeClassHook } = useHandleNodeClass(
154160
data.id,

src/lfx/src/lfx/_assets/component_index.json

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

src/lfx/src/lfx/components/models_and_agents/mcp_component.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,10 @@ def map_outputs(self) -> None:
316316
info="Placeholder for the tool",
317317
value="",
318318
show=False,
319-
tool_mode=False,
319+
# Tool Mode is a capability of the component, not of the selected
320+
# server. Advertising it up front keeps the toolbar from waiting
321+
# for the MCP server's tool-discovery request to finish.
322+
tool_mode=True,
320323
),
321324
]
322325

@@ -663,7 +666,7 @@ async def update_build_config(self, build_config: dict, field_value: str, field_
663666
build_config["tool"]["options"] = []
664667
build_config["tool"]["value"] = ""
665668
build_config["tool"]["placeholder"] = ""
666-
build_config["tool_placeholder"]["tool_mode"] = False
669+
build_config["tool_placeholder"]["tool_mode"] = True
667670
self.remove_non_default_keys(build_config)
668671
return build_config
669672

0 commit comments

Comments
 (0)