test: add a duplicate-tool-names test server for the #1957 repro

Nothing in the repo could serve the shape this fix is about: every preset
registers a unique name, and the SDK's `registerTool` rejects a repeat. So the
only reproduction was a unit test — there was no way to smoke the real app
against a server that returns duplicates.

Adds `ServerConfig.duplicateToolNames` (a list of registered tool names) to the
composable test server, plumbed through the JSON config, and a showcase config
`duplicate-tool-names-http.json`. The named tools are emitted a second time in
`tools/list` with the same `name` and a "(duplicate)" title.

The repeats are appended after the whole list rather than placed beside their
twin. That is both the realistic shape (two tool sources concatenated) and what
makes the defect observable: React matches a leading run of same-key children
first, so a head-adjacent duplicate happens to line up and the bug hides. Only
a separated pair collides in the keyed map and orphans a row — verified both
ways against the prod bundle.

The `tools/list` override is shared with the existing pagination handler (which
already hand-builds the list); with no `maxPageSize` it returns a single
unbounded page. Duplication happens before pagination, so a duplicated pair can
straddle a page boundary the way a real server's would.

Also adds a ToolControls test for this appended ordering, distinct from the
reporter's interleaved fixture, and documents the config in the README showcase
table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
This commit is contained in:
cliffhall 2026-08-10 20:22:47 -04:00
commit 4340283f70
4 changed files with 124 additions and 5 deletions

View file

@ -0,0 +1,17 @@
{
"serverInfo": {
"name": "duplicate-tool-names",
"version": "1.0.0"
},
"tools": [
{ "preset": "get_weather" },
{ "preset": "get_temp" },
{ "preset": "echo" },
{ "preset": "add" }
],
"duplicateToolNames": ["get_weather", "echo"],
"transport": {
"type": "streamable-http",
"port": 3142
}
}

View file

@ -472,6 +472,22 @@ export interface ServerConfig {
resourceTemplates?: number;
prompts?: number;
};
/**
* Emit the named registered tools **twice** in `tools/list`, with the same
* `name` on both entries and " (duplicate)" appended to the second's title.
*
* Nothing else in this repo can produce this shape: every preset registers a
* unique name, and the SDK's `registerTool` rejects a repeat. But a real
* server can and does return duplicates, and the Inspector has to render that
* faithfully rather than break on it (#1957 — duplicate names collided in the
* Tools sidebar's React keys, so filtering left unrelated rows on screen).
*
* Deliberately a list of names rather than a blanket flag, so a config can
* duplicate part of its tool set and leave the rest alone — that mix is what
* makes a search filter's behavior legible. A name that isn't registered is
* ignored.
*/
duplicateToolNames?: string[];
/**
* Gate a tool's visibility in `tools/list` on a client-declared extension
* (SEP-2133 `capabilities.extensions`). Maps extension id → tool name (the
@ -1107,16 +1123,45 @@ export function createMcpServer(config: ServerConfig): McpServer {
// Set up pagination handlers if maxPageSize is configured
const maxPageSize = config.maxPageSize || {};
// Tools pagination
if (capabilities.tools && maxPageSize.tools !== undefined) {
// Emit each named tool a second time, same `name`, title marked so the two
// rows are told apart on screen. See ServerConfig.duplicateToolNames (#1957).
//
// The second copies go **after** the whole list rather than beside their
// twin, which is both how a real server produces duplicates (two tool sources
// concatenated) and what makes the defect observable: React's child
// reconciliation walks a matching prefix first, so head-adjacent duplicates
// happen to line up and survive. It is the *separated* pair that collides in
// the keyed map and orphans a row.
const duplicateToolNames = new Set(config.duplicateToolNames ?? []);
const withDuplicates = (tools: Tool[]): Tool[] =>
duplicateToolNames.size === 0
? tools
: [
...tools,
...tools
.filter((tool) => duplicateToolNames.has(tool.name))
.map((tool) => ({
...tool,
title: `${tool.title ?? tool.name} (duplicate)`,
})),
];
// Tools pagination, and/or the duplicate-name override — both need the same
// hand-built list, so the handler is installed when either is configured.
if (
capabilities.tools &&
(maxPageSize.tools !== undefined || duplicateToolNames.size > 0)
) {
mcpServer.server.setRequestHandler("tools/list", async (request) => {
const cursor = request.params?.cursor;
const pageSize = maxPageSize.tools!;
// No pagination configured: one page holding everything, so the duplicate
// override can share this handler without inventing a page size.
const pageSize = maxPageSize.tools ?? Number.MAX_SAFE_INTEGER;
// Convert registered tools to Tool format, mirroring the SDK's tools/list.
// The input-schema JSON comes from the SDK's memoised converter; the
// output-schema JSON is the value the SDK cached at registration.
const allTools: Tool[] = [];
const registeredTools: Tool[] = [];
for (const [name, registered] of state.registeredTools.entries()) {
if (registered.enabled) {
const toolDefinition: Record<string, unknown> = {
@ -1134,9 +1179,12 @@ export function createMcpServer(config: ServerConfig): McpServer {
toolDefinition.outputSchema = registered.outputSchemaJson;
}
allTools.push(toolDefinition as Tool);
registeredTools.push(toolDefinition as Tool);
}
}
// Duplicate before paginating, so a duplicated pair can straddle a page
// boundary exactly as a real server's would.
const allTools = withDuplicates(registeredTools);
const startIndex = cursor ? parseInt(cursor, 10) : 0;
const endIndex = startIndex + pageSize;

View file

@ -66,6 +66,12 @@ export interface ConfigFile {
resourceTemplates?: number;
prompts?: number;
};
/**
* Names of registered tools to emit **twice** in `tools/list` (same `name`,
* the second's title marked "(duplicate)") — the nonconforming-but-real shape
* no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957).
*/
duplicateToolNames?: string[];
/**
* Gate a tool's `tools/list` visibility on a client-declared extension. Maps
* extension id → tool name; the tool appears only when the connected client

View file

@ -91,6 +91,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
tasks: config.tasks,
tasksExtension: config.tasksExtension,
maxPageSize: config.maxPageSize,
duplicateToolNames: config.duplicateToolNames,
extensionGatedTools: config.extensionGatedTools,
serverType: isHttp
? (transport.type as "sse" | "streamable-http")