inspector/clients/web/src/components/groups/ToolControls/ToolControls.tsx
cliffhall 6f535356b4 fix: key tool rows by source position so duplicate names filter correctly
`ToolControls` keyed both the main list and the SEP-2243 excluded list by
`tool.name`. A server may legitimately return the same tool name more than
once, and colliding React keys let a filtered-out row survive reconciliation:
searching narrows the array, React matches the surviving rows against the
stale keys, and an unrelated row stays mounted (plus a "two children with the
same key" console warning).

Stamp each row's position in the unfiltered list before filtering and use
`<index>:<name>` as the key, on both lists. The position is captured before
the filter, so the key stays stable as the search narrows. No deduplication,
no change to what `tools/list` returns, and the name/title search behavior is
unchanged.

Adds regression tests for both lists using the reporter's fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
2026-08-10 19:56:38 -04:00

197 lines
6.4 KiB
TypeScript

import {
Divider,
Group,
ScrollArea,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import { RiErrorWarningLine } from "react-icons/ri";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type { Tool } from "@modelcontextprotocol/client";
import type { ExcludedTool } from "@inspector/core/mcp/types.js";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
import {
ListPaginationControls,
type ListPaginationControlsProps,
} from "../../elements/ListPaginationControls/ListPaginationControls";
import { ToolListItem } from "../ToolListItem/ToolListItem";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
export interface ToolControlsProps {
tools: Tool[];
/** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header`
* annotations (SEP-2243), shown below the list with the reason (#1632). */
excludedTools?: ExcludedTool[];
selectedName?: string;
// Search text is controlled by the parent (App, via ToolsScreen) so it
// persists across tab navigation within a live session — see #1417.
searchText?: string;
listChanged: boolean;
onRefreshList: () => void;
/**
* A failed list load, surfaced above the list instead of leaving the panel
* empty (which reads as "this server has none") (#1953).
*/
loadError?: Error | null;
/** Pagination controls (#1721). */
pagination: ListPaginationControlsProps;
onSearchChange: (value: string) => void;
onSelectTool: (name: string) => void;
}
// One excluded tool: a warning icon, the tool name (struck through, since it is
// not callable), and its reason on hover. `wrap: nowrap` keeps the icon pinned.
const ExcludedRow = Group.withProps({
gap: "xs",
wrap: "nowrap",
align: "center",
});
const ExcludedWarningIcon = ThemeIcon.withProps({
size: "sm",
variant: "transparent",
c: "var(--inspector-log-warning)",
"aria-hidden": true,
});
const ExcludedName = Text.withProps({
size: "sm",
td: "line-through",
c: "var(--inspector-text-secondary)",
truncate: "end",
});
// Fill the full-height `sidebar` Card (a flex column) so the scroll region
// below claims all the remaining space under the fixed title/search — the
// list runs to the bottom of the card before it scrolls, instead of being
// capped short by a fixed max-height. `mih: 0` lets the scroll child shrink
// and scroll rather than overflow the card.
const SidebarStack = Stack.withProps({
gap: "sm",
flex: 1,
mih: 0,
});
// h3 (not h4), size h4: the sampling/elicitation request modals open over this
// screen with an `h2` `Modal.Title`, so an `h4` section would skip a level
// (axe `heading-order`); `size="h4"` keeps the look.
const ToolsTitle = Title.withProps({
order: 3,
size: "h4",
});
const SearchInput = TextInput.withProps({
placeholder: "Search tools...",
rightSectionPointerEvents: "auto",
});
const SidebarScroll = ScrollArea.withProps({
flex: 1,
mih: 0,
});
const ExcludedDivider = Divider.withProps({
label: "Excluded (SEP-2243)",
labelPosition: "left",
mt: "sm",
});
const ExcludedTooltip = Tooltip.withProps({
multiline: true,
w: 280,
withArrow: true,
position: "right",
});
// A server may return the same tool name more than once, so the name alone is
// not a unique React key — colliding keys let a filtered-out row survive
// reconciliation instead of unmounting (#1957). The tool's position in the
// unfiltered list disambiguates duplicates and stays stable while the search
// narrows, since it is captured before filtering.
const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`;
/** Matches a tool against the (already lower-cased) search query by name or title. */
const matchesQuery = (tool: Tool, query: string) =>
tool.name.toLowerCase().includes(query) ||
(tool.title?.toLowerCase().includes(query) ?? false);
export function ToolControls({
tools,
excludedTools = [],
selectedName,
searchText = "",
listChanged,
onRefreshList,
loadError,
pagination,
onSearchChange,
onSelectTool,
}: ToolControlsProps) {
const viewportRef = useScrollMemory("tools-sidebar");
const query = searchText.toLowerCase();
// Stamp each row's source position before filtering, so the key survives the
// list narrowing (#1957).
const filteredTools = tools
.map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) }))
.filter(({ tool }) => !searchText || matchesQuery(tool, query));
// Excluded tools are searchable too, matching name AND title like the main
// list above, so a filtered view stays consistent.
const filteredExcluded = excludedTools
.map((excluded, sourceIndex) => ({
...excluded,
key: rowKey(excluded.tool.name, sourceIndex),
}))
.filter(({ tool }) => !searchText || matchesQuery(tool, query));
return (
<SidebarStack>
<Group justify="space-between">
<ToolsTitle>Tools</ToolsTitle>
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
</Group>
<SearchInput
value={searchText}
onChange={(e) => onSearchChange(e.currentTarget.value)}
rightSection={
searchText ? <ClearButton onClick={() => onSearchChange("")} /> : null
}
/>
<ListPaginationControls {...pagination} />
<ListLoadError error={loadError} what="tools" onRetry={onRefreshList} />
<SidebarScroll viewportRef={viewportRef}>
<Stack gap="xs">
{filteredTools.map(({ tool, key }) => (
<ToolListItem
key={key}
tool={tool}
selected={tool.name === selectedName}
onClick={() => {
if (tool.name !== selectedName) onSelectTool(tool.name);
}}
/>
))}
{filteredExcluded.length > 0 && (
<>
<ExcludedDivider />
{filteredExcluded.map(({ tool, reason, key }) => (
<ExcludedTooltip key={key} label={reason}>
<ExcludedRow>
<ExcludedWarningIcon>
<RiErrorWarningLine />
</ExcludedWarningIcon>
<ExcludedName>{tool.name}</ExcludedName>
</ExcludedRow>
</ExcludedTooltip>
))}
</>
)}
</Stack>
</SidebarScroll>
</SidebarStack>
);
}