fix: duplicate any listed resource URI, not only a registered one

Copilot on #2281: the option's contract said an unregistered URI is ignored,
but `withDuplicateResources` runs on the assembled list, which also carries
whatever a resource template's `list` callback contributed — so a
template-listed URI was duplicated in contradiction of its own doc.

Broadened rather than restricted, because scoping it to `registeredResources`
would leave half the surface unreproducible: both kinds of entry land in the
same Resources sidebar list and collide on the same React key. The contract now
says so on `ServerConfig.duplicateResourceUris` and on the config-file mirror,
and an integration case covers a template-listed URI — it fails if the override
is narrowed to `state.registeredResources`.

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 20:15:20 -04:00
commit 339c5f4e27
3 changed files with 40 additions and 7 deletions

View file

@ -8,6 +8,7 @@ import {
type TestServerHttp,
createTestServerInfo,
createNumberedResources,
createFileResourceTemplate,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
@ -107,6 +108,28 @@ describe("duplicate resource URIs in resources/list (#2206)", () => {
]);
});
// The option matches the assembled list, not `state.registeredResources`, so
// a URI a resource template contributed is duplicated too (Copilot). Both
// kinds land in the same Resources sidebar list and collide on the same React
// key, so the fixture has to be able to reproduce either.
it("duplicates a URI a resource template listed", async () => {
const started = await start({
resources: [],
resourceTemplates: [
createFileResourceTemplate(undefined, () => ["file:///notes.md"]),
],
duplicateResourceUris: ["file:///notes.md"],
});
const connected = await connect(started.url);
const { resources } = await connected.listAllResources();
expect(resources.map((r) => r.uri)).toEqual([
"file:///notes.md",
"file:///notes.md",
]);
expect(resources.at(-1)?.title).toBe("file:///notes.md (duplicate)");
});
it("ignores a URI that is not registered", async () => {
const started = await start({
duplicateResourceUris: ["test://not_a_resource"],

View file

@ -515,8 +515,8 @@ export interface ServerConfig {
*/
duplicateToolNames?: string[];
/**
* URIs of registered resources to emit **twice** in `resources/list` (same
* `uri`, the second's title marked "(duplicate)").
* 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
@ -524,10 +524,17 @@ export interface ServerConfig {
* 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. A URI that isn't registered is ignored.
* defect.
*/
duplicateResourceUris?: string[];
/**
@ -1427,8 +1434,10 @@ export function createMcpServer(config: ServerConfig): McpServer {
}
// 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.
// 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

View file

@ -93,8 +93,9 @@ export interface ConfigFile {
*/
duplicateToolNames?: string[];
/**
* URIs of registered resources to emit **twice** in `resources/list` (same
* `uri`, the second's title marked "(duplicate)"). See
* 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[];