fix(web): key tool selection by row identity, not name

#1961 keyed the sidebar's React children by source position, but everything
downstream still identified a tool by `tool.name`. With a `tools/list` that
repeats a name, both copies highlighted at once, the click guard made the
other copy a no-op, and `tools.find((t) => t.name === …)` always resolved to
the first — so a later copy rendered but could never be opened.

Thread the row key through selection instead:

- `utils/toolUtils.ts` now owns `toolRowKey` (moved out of ToolControls) and
  adds `findToolByRowKey`, which compares computed keys rather than parsing
  one so the two can't drift.
- `ToolControls`: `selectedName` → `selectedKey`; the highlight and the click
  guard compare keys, and `onSelectTool` emits the row key.
- `ToolsScreen`: `ToolsUiState.selectedToolName` → `selectedToolKey`, with
  both lookups going through `findToolByRowKey`. Renamed through
  `screenUiState.ts` and `App.tsx`'s `onToolsUiChange`.

`onCallTool` still sends `selectedTool.name` — the duplicated name is the
genuine wire identity; only the UI identity needed to be unique.

Also prefixes the excluded (SEP-2243) rows' keys: they render into the same
`Stack` as the list rows but index within their own list, so an excluded tool
sharing a name and index with a listed one collided in the key space.

Closes #2001

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 00:02:13 -04:00
commit ab40fb621a
11 changed files with 210 additions and 48 deletions

View file

@ -363,7 +363,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
InspectorView: (props: {
toolCallState?: { status?: string };
toolsUi?: {
selectedToolName?: string;
selectedToolKey?: string;
formValues: Record<string, unknown>;
search: string;
};
@ -384,7 +384,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
onConnectionInfo: () => void;
onToggleConnection: (id: string) => void;
onToolsUiChange: (next: {
selectedToolName?: string;
selectedToolKey?: string;
formValues: Record<string, unknown>;
search: string;
}) => void;
@ -433,7 +433,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
{Object.keys(props.progressByTaskId ?? {}).join(",") || "none"}
</span>
<span data-testid="selected-tool">
{props.toolsUi?.selectedToolName ?? "none"}
{props.toolsUi?.selectedToolKey ?? "none"}
</span>
<span data-testid="tool-search">{props.toolsUi?.search || "none"}</span>
<span data-testid="selected-prompt">
@ -469,7 +469,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
formValues: {},
search: "",
...props.toolsUi,
selectedToolName: "get_acts",
selectedToolKey: "0:get_acts",
})
}
>
@ -481,7 +481,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
formValues: {},
search: "",
...props.toolsUi,
selectedToolName: "other_tool",
selectedToolKey: "1:other_tool",
})
}
>
@ -869,7 +869,7 @@ describe("App session-scoped state reset on disconnect", () => {
expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"),
);
// A search keystroke leaves `selectedToolName` unchanged, so the result
// A search keystroke leaves `selectedToolKey` unchanged, so the result
// stays put.
await user.click(screen.getByText("set-tool-search"));
await waitFor(() =>

View file

@ -3160,17 +3160,17 @@ function App() {
// drops the previous tool's result — the result panel renders `toolCallState`
// regardless of selection, so without this a stale result would linger under
// the newly-selected tool (which has no result of its own yet). Search and
// form edits keep `selectedToolName` unchanged, so they leave the result be.
// Depends on `selectedToolName` only (not the whole `toolsUi`), so a search
// form edits keep `selectedToolKey` unchanged, so they leave the result be.
// Depends on `selectedToolKey` only (not the whole `toolsUi`), so a search
// keystroke doesn't churn the callback identity.
const onToolsUiChange = useCallback(
(next: ToolsUiState) => {
if (next.selectedToolName !== toolsUi.selectedToolName) {
if (next.selectedToolKey !== toolsUi.selectedToolKey) {
setToolCallState(undefined);
}
setToolsUi(next);
},
[toolsUi.selectedToolName],
[toolsUi.selectedToolKey],
);
// --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel),

View file

@ -45,7 +45,8 @@ export const Default: Story = {
export const WithSelection: Story = {
args: {
tools: longToolList,
selectedName: "query_database",
// Row identity is `index:name`, so the selection names the position too.
selectedKey: "1:query_database",
},
};

View file

@ -83,7 +83,7 @@ describe("ToolControls", () => {
<ToolControls {...baseProps} onSelectTool={onSelectTool} />,
);
await user.click(screen.getByText("git_status"));
expect(onSelectTool).toHaveBeenCalledWith("git_status");
expect(onSelectTool).toHaveBeenCalledWith("2:git_status");
});
it("does not invoke onSelectTool when the already-selected tool is clicked", async () => {
@ -92,7 +92,7 @@ describe("ToolControls", () => {
renderWithMantine(
<ToolControls
{...baseProps}
selectedName="git_status"
selectedKey="2:git_status"
onSelectTool={onSelectTool}
/>,
);
@ -100,6 +100,40 @@ describe("ToolControls", () => {
expect(onSelectTool).not.toHaveBeenCalled();
});
it("selects each copy of a duplicated tool name independently", async () => {
// A `tools/list` may repeat a name. Selection is keyed by row identity, so
// selecting the first copy must leave the second clickable and unhighlighted
// (#2001 — with a name-keyed selection both highlighted and neither of the
// later copies could be opened).
const user = userEvent.setup();
const onSelectTool = vi.fn();
const duplicated: Tool[] = [
{ name: "get_weather", inputSchema: { type: "object" } },
{ name: "echo", inputSchema: { type: "object" } },
{ name: "get_weather", inputSchema: { type: "object" } },
];
renderWithMantine(
<ToolControls
{...baseProps}
tools={duplicated}
selectedKey="0:get_weather"
onSelectTool={onSelectTool}
/>,
);
const rows = screen.getAllByText("get_weather");
expect(rows).toHaveLength(2);
// Only the selected row carries the highlight background.
const highlighted = rows
.map((row) => row.closest("button"))
.filter((button) => button?.style.background !== "");
expect(highlighted).toHaveLength(1);
expect(highlighted[0]).toBe(rows[0]?.closest("button"));
// The second copy is still clickable, and reports its own row key.
await user.click(rows[1] as HTMLElement);
expect(onSelectTool).toHaveBeenCalledWith("2:get_weather");
});
it("does not show the list-changed indicator when listChanged is false", () => {
renderWithMantine(<ToolControls {...baseProps} />);
expect(screen.queryByText("List updated")).not.toBeInTheDocument();

View file

@ -23,13 +23,19 @@ import {
} from "../../elements/ListPaginationControls/ListPaginationControls";
import { ToolListItem } from "../ToolListItem/ToolListItem";
import { useScrollMemory } from "../../../hooks/useScrollMemory";
import { toolRowKey } from "../../../utils/toolUtils";
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;
/**
* The selected row's {@link toolRowKey}, not its name — a `tools/list` may
* repeat a name, and a name-keyed selection would highlight every copy at
* once and make the others unclickable (#2001).
*/
selectedKey?: 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;
@ -49,7 +55,8 @@ export interface ToolControlsProps {
/** Pagination controls (#1721). */
pagination: ListPaginationControlsProps;
onSearchChange: (value: string) => void;
onSelectTool: (name: string) => void;
/** Emits the clicked row's {@link toolRowKey} (see `selectedKey`). */
onSelectTool: (key: string) => void;
}
// One excluded tool: a warning icon, the tool name (struck through, since it is
@ -116,12 +123,12 @@ const ExcludedTooltip = Tooltip.withProps({
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}`;
// Excluded rows render into the same `Stack` as the list rows above them, so
// their keys share one namespace — and an excluded tool's index is counted
// within its own list, which would collide with a listed tool of the same name
// at the same index. The prefix keeps the two spaces apart.
const excludedRowKey = (name: string, sourceIndex: number) =>
`excluded:${toolRowKey(name, sourceIndex)}`;
/** Matches a tool against the (already lower-cased) search query by name or title. */
const matchesQuery = (tool: Tool, query: string) =>
@ -131,7 +138,7 @@ const matchesQuery = (tool: Tool, query: string) =>
export function ToolControls({
tools,
excludedTools = [],
selectedName,
selectedKey,
searchText = "",
listChanged,
onRefreshList,
@ -146,14 +153,17 @@ export function ToolControls({
// 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) }))
.map((tool, sourceIndex) => ({
tool,
key: toolRowKey(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),
key: excludedRowKey(excluded.tool.name, sourceIndex),
}))
.filter(({ tool }) => !searchText || matchesQuery(tool, query));
@ -183,9 +193,9 @@ export function ToolControls({
<ToolListItem
key={key}
tool={tool}
selected={tool.name === selectedName}
selected={key === selectedKey}
onClick={() => {
if (tool.name !== selectedName) onSelectTool(tool.name);
if (key !== selectedKey) onSelectTool(key);
}}
/>
))}

View file

@ -82,6 +82,46 @@ describe("ToolsScreen", () => {
).not.toBeInTheDocument();
});
it("opens the second copy of a duplicated tool name and calls it by its protocol name", async () => {
// A `tools/list` may repeat a name with a different schema. Selection is
// keyed by row identity, so the later copy is reachable and the detail
// panel shows *its* schema — a name-based lookup always resolved the first
// (#2001). The wire identity stays the duplicated name.
const user = userEvent.setup();
const onCallTool = vi.fn();
const duplicated: Tool[] = [
{
name: "get_weather",
inputSchema: {
type: "object",
properties: { city: { type: "string" } },
},
},
{
name: "get_weather",
inputSchema: {
type: "object",
properties: { zip: { type: "string", default: "94103" } },
},
},
];
renderWithMantine(
<ControlledToolsScreen tools={duplicated} onCallTool={onCallTool} />,
);
const rows = screen.getAllByText("get_weather");
await user.click(rows[1] as HTMLElement);
// The second copy's own field renders — the first copy's does not.
expect(screen.getByLabelText(/zip/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/city/i)).toBeNull();
await user.click(screen.getByRole("button", { name: /Execute/ }));
expect(onCallTool).toHaveBeenCalledWith(
"get_weather",
{ zip: "94103" },
false,
);
});
it("filters the sidebar list as the search text changes", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledToolsScreen />);
@ -111,7 +151,7 @@ describe("ToolsScreen", () => {
renderWithMantine(
<ToolsScreen
{...baseProps}
ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }}
ui={{ ...EMPTY_TOOLS_UI, selectedToolKey: "0:alpha" }}
callState={{
status: "ok",
result: { content: [{ type: "text", text: "ok" }] },
@ -134,7 +174,7 @@ describe("ToolsScreen", () => {
function Host() {
const [ui, setUi] = useState<ToolsUiState>({
...EMPTY_TOOLS_UI,
selectedToolName: "alpha",
selectedToolKey: "0:alpha",
});
const [callState, setCallState] = useState<ToolsScreenProps["callState"]>(
{
@ -264,7 +304,7 @@ describe("ToolsScreen", () => {
renderWithMantine(
<ToolsScreen
{...baseProps}
ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }}
ui={{ ...EMPTY_TOOLS_UI, selectedToolKey: "0:alpha" }}
callState={{ status: "pending" }}
/>,
);
@ -277,7 +317,7 @@ describe("ToolsScreen", () => {
renderWithMantine(
<ToolsScreen
{...baseProps}
ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }}
ui={{ ...EMPTY_TOOLS_UI, selectedToolKey: "0:alpha" }}
onCancelCall={onCancelCall}
callState={{ status: "pending" }}
/>,

View file

@ -16,6 +16,7 @@ import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel";
import { ToolCallErrorPanel } from "../../groups/ToolResultPanel/ToolCallErrorPanel";
import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils";
import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils";
import { findToolByRowKey } from "../../../utils/toolUtils";
export interface ToolCallState {
status: "idle" | "pending" | "ok" | "error";
@ -35,7 +36,14 @@ export interface ToolCallState {
// as one object so they persist across tab navigation within a live session;
// the screen unmounts on tab switch, so local state would be lost (#1414/#1417).
export interface ToolsUiState {
selectedToolName?: string;
/**
* The selected row's `toolRowKey` — its position in the list plus its name,
* not the name alone. A `tools/list` may repeat a name, and identifying the
* selection by name highlighted every copy while the detail panel always
* resolved the first, so the later copies could not be inspected (#2001).
* The protocol name a `tools/call` sends still comes from the resolved tool.
*/
selectedToolKey?: string;
formValues: Record<string, unknown>;
search: string;
// Screen-level "Run as task" toggle, shared across tools (not per-tool):
@ -168,27 +176,25 @@ export function ToolsScreen({
onClearResult,
onReadResource,
}: ToolsScreenProps) {
const { selectedToolName, formValues, search } = ui;
const selectedTool = selectedToolName
? tools.find((t) => t.name === selectedToolName)
: undefined;
const { selectedToolKey, formValues, search } = ui;
const selectedTool = findToolByRowKey(tools, selectedToolKey);
const isExecuting = callState?.status === "pending";
const handleSelectTool = (name: string) => {
const handleSelectTool = (key: string) => {
// Seed the form with the tool's schema defaults so default-only fields the
// user never edits are still sent on execute (the form shows defaults via
// resolveValue, but onChange only writes edited fields).
const tool = tools.find((t) => t.name === name);
// `name` always comes from the rendered tools list (ToolControls only emits
// names it was given), so the lookup never misses; the empty-object fallback
// is an unreachable defensive default.
const tool = findToolByRowKey(tools, key);
// `key` always comes from the rendered tools list (ToolControls only emits
// keys it computed from the tools it was given), so the lookup never misses;
// the empty-object fallback is an unreachable defensive default.
let nextFormValues: Record<string, unknown> = {};
/* v8 ignore next -- unreachable: onSelectTool always names a tool in the list */
/* v8 ignore next -- unreachable: onSelectTool always keys a tool in the list */
if (tool)
nextFormValues = collectSchemaDefaults(
toFormSchema(tool.inputSchema) ?? {},
);
onUiChange({ ...ui, selectedToolName: name, formValues: nextFormValues });
onUiChange({ ...ui, selectedToolKey: key, formValues: nextFormValues });
};
return (
@ -199,7 +205,7 @@ export function ToolsScreen({
tools={tools}
excludedTools={excludedTools}
malformedListItems={malformedListItems}
selectedName={selectedToolName}
selectedKey={selectedToolKey}
searchText={search}
listChanged={listChanged}
onRefreshList={onRefreshList}

View file

@ -16,7 +16,7 @@ import { ALL_LEVELS_VISIBLE } from "./LoggingScreen/logLevels";
import { ALL_CATEGORIES_VISIBLE } from "./NetworkScreen/fetchCategories";
export const EMPTY_TOOLS_UI: ToolsUiState = {
selectedToolName: undefined,
selectedToolKey: undefined,
formValues: {},
search: "",
runAsTask: false,

View file

@ -80,7 +80,7 @@ describe("oauthResume", () => {
tabUi: {
Tools: {
...EMPTY_TOOLS_UI,
selectedToolName: "echo",
selectedToolKey: "0:echo",
formValues: { message: "hi" },
},
},
@ -96,7 +96,7 @@ describe("oauthResume", () => {
it("builds and restores tab ui snapshots", () => {
const toolsUi = {
...EMPTY_TOOLS_UI,
selectedToolName: "get_temp",
selectedToolKey: "1:get_temp",
formValues: { city: "NYC" },
};
const tabUi = buildTabUiSnapshot({
@ -186,7 +186,7 @@ describe("oauthResume", () => {
it("applyOAuthResumeUi restores tab ui, active tab, and clears in-flight panels", () => {
const toolsUi = {
...EMPTY_TOOLS_UI,
selectedToolName: "get_temp",
selectedToolKey: "1:get_temp",
formValues: { city: "NYC" },
};
const snapshot: OAuthResumeSnapshot = {

View file

@ -1,6 +1,11 @@
import { describe, it, expect } from "vitest";
import type { Tool } from "@modelcontextprotocol/client";
import { hasInputFields, resolveDisplayLabel } from "./toolUtils";
import {
findToolByRowKey,
hasInputFields,
resolveDisplayLabel,
toolRowKey,
} from "./toolUtils";
describe("resolveDisplayLabel", () => {
it("returns the title when provided", () => {
@ -47,3 +52,37 @@ describe("hasInputFields", () => {
).toBe(true);
});
});
describe("toolRowKey / findToolByRowKey", () => {
const tool = (name: string): Tool => ({
name,
inputSchema: { type: "object" },
});
// A `tools/list` may legitimately repeat a name (#1957/#2001).
const tools: Tool[] = [
tool("get_weather"),
tool("echo"),
tool("get_weather"),
];
it("distinguishes two tools that share a name", () => {
expect(toolRowKey("get_weather", 0)).not.toBe(toolRowKey("get_weather", 2));
});
it("resolves each duplicate to its own entry", () => {
expect(findToolByRowKey(tools, "0:get_weather")).toBe(tools[0]);
// The second copy — the one a name-based lookup could never reach.
expect(findToolByRowKey(tools, "2:get_weather")).toBe(tools[2]);
});
it("returns undefined for no key", () => {
expect(findToolByRowKey(tools, undefined)).toBeUndefined();
});
it("returns undefined for a key naming no current row", () => {
// A stale selection after the list changed underneath it.
expect(findToolByRowKey(tools, "9:gone")).toBeUndefined();
// Right name, wrong position: still not a match.
expect(findToolByRowKey(tools, "1:get_weather")).toBeUndefined();
});
});

View file

@ -19,3 +19,35 @@ export function resolveDisplayLabel(name: string, title?: string): string {
export function hasInputFields(tool: Tool): boolean {
return Object.keys(tool.inputSchema.properties ?? {}).length > 0;
}
/**
* A tool's stable per-row identity in the Tools sidebar.
*
* A server may return the same tool name more than once, so the name alone
* identifies neither a React child nor a selection: colliding keys let a
* filtered-out row survive reconciliation (#1957), and a name-keyed selection
* highlights both copies at once while the detail panel can only ever resolve
* the first (#2001). The tool's position in the *unfiltered* list disambiguates
* duplicates and stays stable while the search narrows, since it is captured
* before filtering.
*
* This is a UI identity only — the wire identity is still `tool.name`, which is
* what a `tools/call` must send.
*/
export function toolRowKey(name: string, sourceIndex: number): string {
return `${sourceIndex}:${name}`;
}
/**
* Resolves the tool a {@link toolRowKey} refers to, or `undefined` when the key
* names no row in the current list (e.g. the list changed under a stale
* selection). Compares computed keys rather than parsing the key, so the two
* stay in lockstep if the format ever changes.
*/
export function findToolByRowKey(
tools: Tool[],
key: string | undefined,
): Tool | undefined {
if (key === undefined) return undefined;
return tools.find((tool, index) => toolRowKey(tool.name, index) === key);
}