Merge pull request #2041 from modelcontextprotocol/v2/fix/2001-duplicate-tool-selection

fix(web): key tool selection by row identity so duplicate tool names are individually inspectable
This commit is contained in:
Cliff Hall 2026-08-17 08:19:42 -04:00 • committed by GitHub
commit 5dd038eb21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 270 additions and 56 deletions

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