fix: key resource list rows by position so duplicate URIs cannot collide

The Resources sidebar keyed every row on its `uri` (and templates on
`uriTemplate`), but nothing in MCP makes those unique — a server may return
the same URI twice, and `ManagedListState.applyItems` replaces the list
wholesale, so a repeat is what the server actually sent. React logged
`Encountered two children with the same key` on every render, and a
filtered-out row survived reconciliation instead of unmounting.

This is the resources counterpart of #1957. The row identity is now the same
`listRowKey(id, sourceIndex)` the Tools sidebar has used since then, extracted
so the two formats cannot drift; `toolRowKey` delegates to it. Applied to all
three Resources lists and to `RootsTable`, whose user-maintained list can
collect a duplicate URI the same way.

Selection stays keyed on the URI, deliberately: unlike a duplicated tool name
(#2001), duplicate rows here denote the same resource — a `resources/read`
takes the URI — so highlighting both is correct. Only the React key needs to
tell them apart.

Adds `ServerConfig.duplicateResourceUris` and the
`duplicate-resource-uris-http.json` showcase config, the `resources/list`
mirror of `duplicateToolNames`, so the defect has a fixture to reproduce it —
appended rather than adjacent, for the reason #1957 documented.

Closes #2206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yVgAJz4wpkS4mkGk7DQnp
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-06 19:43:47 -04:00
commit 3153f0305a
3 changed files with 534 additions and 32 deletions

View file

@ -514,6 +514,22 @@ export interface ServerConfig {
* ignored.
*/
duplicateToolNames?: string[];
/**
* URIs of registered resources to emit **twice** in `resources/list` (same
* `uri`, the second's title marked "(duplicate)").
*
* The `resources/list` analogue of {@link duplicateToolNames}, and
* unreachable the same way: `registerResource` keys on the URI, so no preset
* can produce a repeat. A real server can — two resource sources
* concatenated — and the Inspector has to render that faithfully rather than
* collide on the React key (#2206).
*
* The copies go **after** the whole list rather than beside their twin, for
* the reason spelled out on `duplicateToolNames`: a head-adjacent pair
* happens to survive reconciliation, so only a separated pair exposes the
* defect. A URI that isn't registered is ignored.
*/
duplicateResourceUris?: string[];
/**
* Replace a registered tool's `inputSchema` / `outputSchema` in `tools/list`
* with a **raw** JSON Schema document (#1005).
@ -1410,13 +1426,37 @@ export function createMcpServer(config: ServerConfig): McpServer {
});
}
// Resources pagination
if (capabilities.resources && maxPageSize.resources !== undefined) {
// Emit each named resource a second time, same `uri`, title marked so the two
// rows are told apart on screen. See ServerConfig.duplicateResourceUris
// (#2206) — and the note there on why the copies are appended.
const duplicateResourceUris = new Set(config.duplicateResourceUris ?? []);
const withDuplicateResources = (resources: Resource[]): Resource[] =>
duplicateResourceUris.size === 0
? resources
: [
...resources,
...resources
.filter((resource) => duplicateResourceUris.has(resource.uri))
.map((resource) => ({
...resource,
title: `${resource.title ?? resource.name} (duplicate)`,
})),
];
// Resources pagination, and the duplicate-URI override, both need the same
// hand-built list, so the handler is installed when either is configured.
if (
capabilities.resources &&
(maxPageSize.resources !== undefined || duplicateResourceUris.size > 0)
) {
mcpServer.server.setRequestHandler(
"resources/list",
async (request, ctx) => {
const cursor = request.params?.cursor;
const pageSize = maxPageSize.resources!;
// No pagination configured: one page holding everything, so the
// duplicate override can share this handler without inventing a page
// size — mirroring the `tools/list` handler above.
const pageSize = maxPageSize.resources ?? Number.MAX_SAFE_INTEGER;
const codec = cursorCodec(pageSize);
// Collect all resources (static + from templates)
@ -1458,11 +1498,12 @@ export function createMcpServer(config: ServerConfig): McpServer {
}
}
const listed = withDuplicateResources(allResources);
const startIndex = codec.decode(cursor);
const endIndex = startIndex + pageSize;
const page = allResources.slice(startIndex, endIndex);
const page = listed.slice(startIndex, endIndex);
const nextCursor =
endIndex < allResources.length ? codec.encode(endIndex) : undefined;
endIndex < listed.length ? codec.encode(endIndex) : undefined;
return {
resources: page,

View file

@ -92,6 +92,12 @@ export interface ConfigFile {
* no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957).
*/
duplicateToolNames?: string[];
/**
* URIs of registered resources to emit **twice** in `resources/list` (same
* `uri`, the second's title marked "(duplicate)"). See
* {@link ServerConfig.duplicateResourceUris} (#2206).
*/
duplicateResourceUris?: string[];
/**
* Replace a registered tool's advertised `inputSchema`/`outputSchema` with a
* raw JSON Schema document — the constructs a Zod-built preset cannot emit.

View file

@ -95,6 +95,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
maxPageSize: config.maxPageSize,
emptyStringCursor: config.emptyStringCursor,
duplicateToolNames: config.duplicateToolNames,
duplicateResourceUris: config.duplicateResourceUris,
rawToolSchemas: config.rawToolSchemas,
extensionGatedTools: config.extensionGatedTools,
serverType: isHttp