Merge pull request #2281 from modelcontextprotocol/v2/fix/2206-duplicate-resource-uri-keys

fix: key resource list rows by position so duplicate URIs cannot collide
This commit is contained in:
Cliff Hall 2026-09-06 22:29:38 -04:00 • committed by GitHub
commit 849eb08db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 567 additions and 32 deletions

View file

@ -514,6 +514,29 @@ export interface ServerConfig {
* ignored.
*/
duplicateToolNames?: string[];
/**
* URIs 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).
*
* Matched against **everything the list actually carries**, which is the
* static registrations *and* whatever a resource template's `list` callback
* contributes — not `state.registeredResources` alone (Copilot). Both land in
* the same Resources sidebar list and collide on the same React key, so
* scoping this to static registrations only would leave half the surface
* unreproducible. A URI that appears in neither is ignored.
*
* 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.
*/
duplicateResourceUris?: string[];
/**
* Replace a registered tool's `inputSchema` / `outputSchema` in `tools/list`
* with a **raw** JSON Schema document (#1005).
@ -1410,13 +1433,39 @@ 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. Applied to the assembled list, so a URI a
// resource template listed is duplicated exactly as a statically-registered
// one is. 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 +1507,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,13 @@ export interface ConfigFile {
* no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957).
*/
duplicateToolNames?: string[];
/**
* URIs to emit **twice** in `resources/list` (same `uri`, the second's title
* marked "(duplicate)") — matched against the assembled list, so a
* template-listed URI counts as well as a statically-registered one. 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