diff --git a/clients/web/src/test/integration/mcp/duplicate-resource-uris.test.ts b/clients/web/src/test/integration/mcp/duplicate-resource-uris.test.ts index 1e8edd9f..28c9edae 100644 --- a/clients/web/src/test/integration/mcp/duplicate-resource-uris.test.ts +++ b/clients/web/src/test/integration/mcp/duplicate-resource-uris.test.ts @@ -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"], diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 617d7acf..7123873f 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -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 diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 75a37373..4f08e232 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -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[];