,
+ SidebarGroupContent: ({ children }: Children) =>
{children}
,
+ useSidebar: () => ({ setOpen: jest.fn() }),
+}));
+
+// Stand in for ag-grid: hand ToolsTable a grid api it can drive, report ready so the
+// selection effect runs, and expose the suppression prop the grid was configured with.
+jest.mock(
+ "@/components/core/parameterRenderComponent/components/tableComponent",
+ () => ({
+ __esModule: true,
+ default: React.forwardRef(
+ (
+ props: {
+ suppressRowClickSelection?: boolean;
+ onGridReady?: () => void;
+ },
+ ref: React.Ref,
+ ) => {
+ React.useImperativeHandle(ref, () => ({ api: gridApi }));
+ React.useEffect(() => {
+ props.onGridReady?.();
+ // biome-ignore lint/correctness/useExhaustiveDependencies: fire once, like onGridReady
+ }, []);
+ return (
+
+ );
+ },
+ ),
+ }),
+);
+
+jest.mock("@/utils/stringManipulation", () => ({
+ parseString: (str: string) => str,
+ sanitizeMcpName: (str: string) => str,
+}));
+
+const rows = [
+ {
+ name: "search_repositories",
+ display_name: "Search Repositories",
+ description: "Search repositories by name.",
+ display_description: "Search repositories by name.",
+ status: true,
+ tags: ["search_repositories"],
+ readonly: false,
+ },
+ {
+ name: "delete_repository",
+ display_name: "Delete Repository",
+ description: "Permanently delete a repository.",
+ display_description: "Permanently delete a repository.",
+ status: true,
+ tags: ["delete_repository"],
+ readonly: false,
+ },
+];
+
+const defaultProps = {
+ data: [],
+ setData: jest.fn(),
+ isAction: false,
+ placeholder: "Select tools",
+ open: true,
+ handleOnNewValue: jest.fn(),
+};
+
+describe("ToolsTable row-click selection", () => {
+ beforeEach(() => {
+ setGridOption.mockClear();
+ gridNodes.forEach((node) => node.setSelected.mockClear());
+ });
+
+ it("should keep row-click selection suppressed for the whole modal", () => {
+ render();
+
+ // Guard against a vacuous pass: the selection effect must actually have run.
+ expect(
+ gridNodes.some((node) => node.setSelected.mock.calls.length > 0),
+ ).toBe(true);
+
+ // Re-enabling click selection let a click on an already-selected row collapse the
+ // selection to that one row, which set status:false on every other tool and
+ // silently dropped them from the toolset.
+ expect(setGridOption).not.toHaveBeenCalledWith(
+ "suppressRowClickSelection",
+ false,
+ );
+ });
+
+ it("should configure the grid to suppress row-click selection", () => {
+ render();
+ expect(screen.getAllByTestId("grid")[0]).toHaveAttribute(
+ "data-suppress",
+ "true",
+ );
+ });
+});
diff --git a/src/frontend/src/modals/toolsModal/components/toolsTable/index.tsx b/src/frontend/src/modals/toolsModal/components/toolsTable/index.tsx
index 6490481ced20..ef71a93bba34 100644
--- a/src/frontend/src/modals/toolsModal/components/toolsTable/index.tsx
+++ b/src/frontend/src/modals/toolsModal/components/toolsTable/index.tsx
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { parseString, sanitizeMcpName } from "@/utils/stringManipulation";
+import { AccessHintBadge } from "./AccessHintBadge";
import { RequiresApprovalToggle } from "./RequiresApprovalToggle";
export default function ToolsTable({
@@ -120,8 +121,13 @@ export default function ToolsTable({
return;
}
+ // Selection is applied through node.setSelected, which ignores
+ // suppressRowClickSelection: ag-grid reads that option only when handling a real
+ // row click. So this must not touch it. The grid is configured to suppress click
+ // selection for the whole modal, and overriding it here left click selection live,
+ // which made a click on any already-selected row collapse the selection to that
+ // one row and silently disable every other tool.
applyingSelection.current = true;
- agGrid.current.api.setGridOption("suppressRowClickSelection", true);
const selectedIds = new Set(selectedRows.map((row) => row.name));
agGrid.current.api.forEachNode((node) => {
@@ -131,7 +137,6 @@ export default function ToolsTable({
}
});
- agGrid.current.api.setGridOption("suppressRowClickSelection", false);
setTimeout(() => {
applyingSelection.current = false;
}, 50);
@@ -195,6 +200,8 @@ export default function ToolsTable({
}
}, [focusedRow]);
+ const hasAccessHints = rows.some((row) => Boolean(row?.access_hint));
+
const columnDefs: ColDef[] = [
{
field: isAction ? "display_name" : "name",
@@ -244,6 +251,28 @@ export default function ToolsTable({
]),
cellClass: "text-muted-foreground",
},
+ // Only MCP servers declare behavior hints, so the column is dropped entirely rather
+ // than standing empty in every other component's tool table. It sits next to
+ // Requires Approval because it is the input to that decision.
+ ...(hasAccessHints
+ ? [
+ {
+ field: "access_hint",
+ headerName: t("toolsModal.columnAccess", "Access"),
+ width: 120,
+ flex: 0,
+ resizable: false,
+ sortable: false,
+ cellRenderer: (params: {
+ data?: { access_hint?: string | null };
+ }) => (
+
+
+
+ ),
+ },
+ ]
+ : []),
{
field: "approval_actions",
headerName: t("toolsModal.columnApproval", "Requires Approval"),
diff --git a/src/lfx/src/lfx/base/mcp/util.py b/src/lfx/src/lfx/base/mcp/util.py
index cb56747c408e..20b4bc8cc5dc 100644
--- a/src/lfx/src/lfx/base/mcp/util.py
+++ b/src/lfx/src/lfx/base/mcp/util.py
@@ -2544,6 +2544,39 @@ def _maybe_inject_end_user_header(headers: dict, url: str, end_user_id: str | No
return validate_headers({**(headers or {}), header_name: end_user_id})
+ACCESS_HINT_READ_ONLY = "read_only"
+ACCESS_HINT_WRITE = "write"
+ACCESS_HINT_DESTRUCTIVE = "destructive"
+
+
+def _tool_access_hint(tool: Any) -> str | None:
+ """Classify an MCP tool from the server's ``ToolAnnotations`` behavior hints.
+
+ Returns ``None`` when the server declared neither hint. Applying the spec's
+ defaults (``readOnlyHint`` false, ``destructiveHint`` true) to a server that sent
+ no annotations at all would label every one of its tools destructive, which says
+ more about the server's age than about the tool. Once either hint is present the
+ defaults do apply, so ``readOnlyHint: false`` on its own reads as destructive.
+
+ These are hints from the server, and the MCP spec is explicit that a client must
+ not make tool-use decisions from annotations it does not trust. The result is
+ display-only: it tells the flow author which tools are worth gating, and nothing
+ downstream may gate, exempt, or execute a tool based on it.
+ """
+ annotations = getattr(tool, "annotations", None)
+ if annotations is None:
+ return None
+ read_only = getattr(annotations, "readOnlyHint", None)
+ destructive = getattr(annotations, "destructiveHint", None)
+ if read_only is None and destructive is None:
+ return None
+ if read_only:
+ return ACCESS_HINT_READ_ONLY
+ # destructiveHint is meaningful only for a non-read-only tool, and the spec defaults
+ # it to true, so an omitted hint on a writing tool stays destructive.
+ return ACCESS_HINT_WRITE if destructive is False else ACCESS_HINT_DESTRUCTIVE
+
+
async def update_tools(
server_name: str,
server_config: dict,
@@ -2782,7 +2815,11 @@ def _convert_parameters(self, input_dict):
func=create_tool_func(tool.name, args_schema, client),
coroutine=create_tool_coroutine(tool.name, args_schema, client),
tags=[tool.name],
- metadata={"server_name": server_name, "output_schema": getattr(tool, "outputSchema", None)},
+ metadata={
+ "server_name": server_name,
+ "output_schema": getattr(tool, "outputSchema", None),
+ "access_hint": _tool_access_hint(tool),
+ },
response_format="content_and_artifact",
)
diff --git a/src/lfx/src/lfx/custom/custom_component/component.py b/src/lfx/src/lfx/custom/custom_component/component.py
index 6a77c15c3081..4e6847890835 100644
--- a/src/lfx/src/lfx/custom/custom_component/component.py
+++ b/src/lfx/src/lfx/custom/custom_component/component.py
@@ -1826,6 +1826,9 @@ def _build_tool_data(self, tool: Tool) -> dict:
"tags": tool.tags if hasattr(tool, "tags") and tool.tags else [tool.name],
"status": True, # Initialize all tools with status True
"approval_actions": tool.metadata.get("approval_actions") or [], # HITL decisions per action (LE-1447)
+ # Server-declared MCP behavior hint, display-only: it tells the author which
+ # tools are worth gating. Absent for tools whose source declares nothing.
+ "access_hint": tool.metadata.get("access_hint"),
"display_name": tool.metadata.get("display_name", tool.name),
"display_description": tool.metadata.get("display_description", tool.description),
"readonly": tool.metadata.get("readonly", False),
diff --git a/src/lfx/tests/unit/mcp/test_mcp_tool_annotations.py b/src/lfx/tests/unit/mcp/test_mcp_tool_annotations.py
new file mode 100644
index 000000000000..49ce272921cc
--- /dev/null
+++ b/src/lfx/tests/unit/mcp/test_mcp_tool_annotations.py
@@ -0,0 +1,72 @@
+"""MCP ``ToolAnnotations`` behavior hints surfaced to the flow author.
+
+The hint is display-only: it tells the author which tools are worth gating for
+approval. Nothing may gate, exempt, or execute a tool based on it, because the MCP
+spec is explicit that annotations from an untrusted server are not trustworthy.
+"""
+
+import pytest
+from lfx.base.mcp.util import (
+ ACCESS_HINT_DESTRUCTIVE,
+ ACCESS_HINT_READ_ONLY,
+ ACCESS_HINT_WRITE,
+ _tool_access_hint,
+)
+from mcp.types import Tool, ToolAnnotations
+
+
+def _tool(annotations: ToolAnnotations | None) -> Tool:
+ return Tool(name="fetch", description="", inputSchema={"type": "object"}, annotations=annotations)
+
+
+@pytest.mark.parametrize(
+ ("annotations", "expected"),
+ [
+ # A server that sent no annotations at all tells us nothing. Defaulting here
+ # would mark every tool on every un-annotated server destructive.
+ (None, None),
+ (ToolAnnotations(), None),
+ (ToolAnnotations(title="Fetch a page"), None),
+ (ToolAnnotations(readOnlyHint=True), ACCESS_HINT_READ_ONLY),
+ # readOnlyHint wins: destructiveHint is only meaningful for a writing tool.
+ (ToolAnnotations(readOnlyHint=True, destructiveHint=True), ACCESS_HINT_READ_ONLY),
+ (ToolAnnotations(readOnlyHint=False, destructiveHint=False), ACCESS_HINT_WRITE),
+ (ToolAnnotations(destructiveHint=False), ACCESS_HINT_WRITE),
+ (ToolAnnotations(readOnlyHint=False, destructiveHint=True), ACCESS_HINT_DESTRUCTIVE),
+ (ToolAnnotations(destructiveHint=True), ACCESS_HINT_DESTRUCTIVE),
+ # Either hint present means the spec's defaults apply, and destructiveHint
+ # defaults to true, so a declared non-read-only tool stays destructive.
+ (ToolAnnotations(readOnlyHint=False), ACCESS_HINT_DESTRUCTIVE),
+ ],
+)
+def test_access_hint_derivation(annotations, expected):
+ assert _tool_access_hint(_tool(annotations)) == expected
+
+
+def test_access_hint_tolerates_a_tool_without_annotations_support():
+ """Preset and in-tree tools are not MCP tools and carry no annotations attribute."""
+
+ class PlainTool:
+ name = "search"
+
+ assert _tool_access_hint(PlainTool()) is None
+
+
+def test_access_hint_reaches_the_tools_metadata_row():
+ """The row the tools table renders carries the hint through unchanged."""
+ from types import SimpleNamespace
+
+ from lfx.custom.custom_component.component import Component
+
+ tool = SimpleNamespace(
+ name="delete_repo",
+ description="Delete a repository",
+ tags=["delete_repo"],
+ args={},
+ metadata={"access_hint": ACCESS_HINT_DESTRUCTIVE},
+ )
+ row = Component()._build_tool_data(tool)
+
+ assert row["access_hint"] == ACCESS_HINT_DESTRUCTIVE
+ # The hint must not imply a gate: that stays the author's explicit choice.
+ assert row["approval_actions"] == []