diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx index 5cae16a2..af885ba6 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx @@ -452,4 +452,135 @@ describe("ResourceControls", () => { renderWithMantine(); expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); }); + + // Nothing in the protocol makes `resources/list` URIs unique, and the store + // does not dedupe them either — `ManagedListState.applyItems` replaces the + // list wholesale, so a repeat is what the server actually sent. Keying a row + // on the URI alone therefore collides, which React warns about on every + // render and which lets a filtered-out row survive reconciliation (#2206). + describe("duplicate identifiers (#2206)", () => { + const duplicateUriResources: Resource[] = [ + { name: "app", title: "App First", uri: "ui://hello-world/app.html" }, + { name: "notes", title: "Notes", uri: "file:///notes.md" }, + { name: "app", title: "App Second", uri: "ui://hello-world/app.html" }, + ]; + + const duplicateTemplates: ResourceTemplate[] = [ + { name: "profile", title: "Profile First", uriTemplate: "file:///{id}" }, + { name: "logs", title: "Logs", uriTemplate: "log:///{day}" }, + { name: "profile", title: "Profile Second", uriTemplate: "file:///{id}" }, + ]; + + // Same URI, different names — the shape a search can tell apart. The rows + // display the last URI segment, so assertions count rows rather than text. + const duplicateSubscriptions: InspectorResourceSubscription[] = [ + { + resource: { name: "alpha", uri: "ui://hello-world/app.html" }, + lastUpdated: new Date("2026-03-17T10:30:00Z"), + }, + { + resource: { name: "beta", uri: "file:///notes.md" }, + lastUpdated: new Date("2026-03-17T10:31:00Z"), + }, + { + resource: { name: "gamma", uri: "ui://hello-world/app.html" }, + lastUpdated: new Date("2026-03-17T10:32:00Z"), + }, + ]; + + // The console warning was the reported symptom, so assert on it directly: + // React only emits it when two siblings share a key. + it("renders repeated URIs without a React key collision", () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + try { + renderWithMantine( + , + ); + const messages = consoleError.mock.calls.map((call) => + call.map(String).join(" "), + ); + expect( + messages.filter((message) => message.includes("same key")), + ).toEqual([]); + // Every entry the server sent is still on screen, which is the other + // half of what the collision put at risk. + expect(screen.getByText("App First")).toBeInTheDocument(); + expect(screen.getByText("App Second")).toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); + + it("removes every non-matching row when resource URIs repeat", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + + await user.type(screen.getByPlaceholderText("Search..."), "notes"); + + expect(screen.getByText("Notes")).toBeInTheDocument(); + // Both copies are orphaned by the collision on the broken build; the + // second is the one React reuses rather than unmounting. + expect(screen.queryByText("App First")).not.toBeInTheDocument(); + expect(screen.queryByText("App Second")).not.toBeInTheDocument(); + }); + + it("removes every non-matching row when uriTemplates repeat", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + + await user.type(screen.getByPlaceholderText("Search..."), "logs"); + + expect(screen.getByText("Logs")).toBeInTheDocument(); + expect(screen.queryByText("Profile First")).not.toBeInTheDocument(); + expect(screen.queryByText("Profile Second")).not.toBeInTheDocument(); + }); + + it("renders one row per subscription when their URIs repeat", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Subscriptions (3)")).toBeInTheDocument(); + expect( + screen.getAllByRole("button", { name: "Unsubscribe" }), + ).toHaveLength(3); + }); + + it("removes every non-matching row when subscription URIs repeat", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + + await user.type(screen.getByPlaceholderText("Search..."), "beta"); + + expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument(); + expect( + screen.getAllByRole("button", { name: "Unsubscribe" }), + ).toHaveLength(1); + }); + }); }); diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index aa1ee954..1af189df 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -30,6 +30,7 @@ import { type ListPaginationControlsProps, } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ListToggle } from "../../elements/ListToggle/ListToggle"; +import { listRowKey } from "../../../utils/listRowKey"; import { ResourceListItem } from "../ResourceListItem/ResourceListItem"; import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem"; @@ -79,7 +80,15 @@ export interface ResourceControlsProps { subscriptionStreamState?: ResourceSubscriptionStreamState; /** Negotiated protocol era; gates the modern subscription stream chrome. */ protocolEra?: ProtocolEra; + /** + * The selected resource's `uri` — the wire identity, not a row key. A + * duplicated URI therefore highlights every row carrying it, which is + * correct here in a way it was not for tools (#2001): a `resources/read` + * takes the URI, so the duplicate rows all denote the same resource. Only + * the React key needs to distinguish them (#2206). + */ selectedUri?: string; + /** As `selectedUri`, keyed on `uriTemplate`. */ selectedTemplateUri?: string; // Search text + accordion open-sections are controlled by the parent (App, // via ResourcesScreen) so they persist across tab navigation within a live @@ -155,24 +164,43 @@ export function ResourceControls({ onCompactChange, }: ResourceControlsProps) { const query = searchText.toLowerCase(); - const filteredResources = resources.filter( - (r) => - r.name.toLowerCase().includes(query) || - (r.title?.toLowerCase().includes(query) ?? false) || - r.uri.toLowerCase().includes(query), - ); - const filteredTemplates = templates.filter( - (t) => - t.name.toLowerCase().includes(query) || - (t.title?.toLowerCase().includes(query) ?? false) || - t.uriTemplate.toLowerCase().includes(query), - ); - const filteredSubscriptions = subscriptions.filter( - (s) => - s.resource.name.toLowerCase().includes(query) || - (s.resource.title?.toLowerCase().includes(query) ?? false) || - s.resource.uri.toLowerCase().includes(query), - ); + // Each row carries a `listRowKey` computed from its position in the + // *unfiltered* list, because nothing stops a server returning the same `uri` + // or `uriTemplate` twice (#2206). Computed before filtering so the key stays + // stable as a search narrows the view. + const filteredResources = resources + .map((resource, sourceIndex) => ({ + resource, + key: listRowKey(resource.uri, sourceIndex), + })) + .filter( + ({ resource: r }) => + r.name.toLowerCase().includes(query) || + (r.title?.toLowerCase().includes(query) ?? false) || + r.uri.toLowerCase().includes(query), + ); + const filteredTemplates = templates + .map((template, sourceIndex) => ({ + template, + key: listRowKey(template.uriTemplate, sourceIndex), + })) + .filter( + ({ template: t }) => + t.name.toLowerCase().includes(query) || + (t.title?.toLowerCase().includes(query) ?? false) || + t.uriTemplate.toLowerCase().includes(query), + ); + const filteredSubscriptions = subscriptions + .map((subscription, sourceIndex) => ({ + subscription, + key: listRowKey(subscription.resource.uri, sourceIndex), + })) + .filter( + ({ subscription: s }) => + s.resource.name.toLowerCase().includes(query) || + (s.resource.title?.toLowerCase().includes(query) ?? false) || + s.resource.uri.toLowerCase().includes(query), + ); // Modern-era chrome for the single `subscriptions/listen` stream (#1630): // a status badge in the section header (so it stays visible while the section @@ -312,9 +340,9 @@ export function ResourceControls({ - {filteredResources.map((resource) => ( + {filteredResources.map(({ resource, key }) => ( { @@ -338,9 +366,9 @@ export function ResourceControls({ - {filteredTemplates.map((template) => ( + {filteredTemplates.map(({ template, key }) => ( { @@ -381,9 +409,9 @@ export function ResourceControls({ {NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE} )} - {filteredSubscriptions.map((sub) => ( + {filteredSubscriptions.map(({ subscription: sub, key }) => ( onUnsubscribeResource(sub.resource.uri) diff --git a/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx b/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx index a8903eee..6daaa372 100644 --- a/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx +++ b/clients/web/src/components/groups/RootsTable/RootsTable.test.tsx @@ -122,6 +122,35 @@ describe("RootsTable", () => { ).not.toBeInTheDocument(); }); + // The roots list is user-maintained and nothing dedupes it, so the same URI + // can appear twice and collide on the row key (#2206). + it("renders both rows when a root URI repeats (#2206)", () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + try { + renderWithMantine( + , + ); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + const messages = consoleError.mock.calls.map((call) => + call.map(String).join(" "), + ); + expect( + messages.filter((message) => message.includes("same key")), + ).toEqual([]); + } finally { + consoleError.mockRestore(); + } + }); + it("renders the current draft values in the inputs", () => { renderWithMantine( - {roots.map((root) => ( - + {/* Keyed on position as well as URI: nothing dedupes the roots + list, so the same URI can appear twice and collide (#2206). */} + {roots.map((root, index) => ( + {root.name} {root.uri} 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 new file mode 100644 index 00000000..1e8edd9f --- /dev/null +++ b/clients/web/src/test/integration/mcp/duplicate-resource-uris.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createNumberedResources, + loadConfig, + resolveConfig, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of `ServerConfig.duplicateResourceUris` (#2206) — the only way + * this repo can serve a `resources/list` that repeats a URI, since + * `registerResource` keys on the URI and no preset can produce a repeat. + * + * The Resources sidebar keyed its rows by `resource.uri`, so duplicates + * collided: React logged `Encountered two children with the same key` on every + * render and a filtered-out row survived reconciliation. The component-level + * regressions live in `ResourceControls.test.tsx`; this file covers the server + * option the manual repro depends on — the wire shape, the ordering that makes + * the defect observable, and the config plumbing. It mirrors + * `duplicate-tool-names.test.ts` (#1957) deliberately, since the two fixtures + * have to stay the same shape to stay comparable. + */ +describe("duplicate resource URIs in resources/list (#2206)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + async function start(config: Partial): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("duplicate-resource-uris-test", "1.0.0"), + resources: createNumberedResources(2), + ...config, + }); + await started.start(); + server = started; + return started; + } + + async function connect(url: string): Promise { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { environment: { transport: createTransportNode } }, + ); + await connected.connect(); + client = connected; + return connected; + } + + it("emits the named resources twice, repeats appended, second copy titled", async () => { + const started = await start({ + duplicateResourceUris: ["test://resource_1"], + }); + const connected = await connect(started.url); + + const { resources } = await connected.listAllResources(); + + // Repeats go at the END, not beside their twin. That ordering is + // load-bearing: React matches a leading run of same-key children first, so + // an adjacent duplicate lines up and the defect hides. Asserting the exact + // sequence keeps a future "tidy-up" from silently defanging the fixture. + expect(resources.map((r) => r.uri)).toEqual([ + "test://resource_1", + "test://resource_2", + "test://resource_1", + ]); + // These fixtures carry no title, so the marker falls back to the name — + // which is what keeps the two rows distinguishable on screen. + expect(resources.at(-1)?.title).toBe("resource_1 (duplicate)"); + // Only the appended copy is marked; the originals are passed through as-is. + expect(resources[0]?.title).toBeUndefined(); + expect(resources[1]?.title).toBeUndefined(); + }); + + it("leaves the list alone when no URIs are given", async () => { + const started = await start({ duplicateResourceUris: [] }); + const connected = await connect(started.url); + + const { resources } = await connected.listAllResources(); + expect(resources.map((r) => r.uri)).toEqual([ + "test://resource_1", + "test://resource_2", + ]); + }); + + it("ignores a URI that is not registered", async () => { + const started = await start({ + duplicateResourceUris: ["test://not_a_resource"], + }); + const connected = await connect(started.url); + + const { resources } = await connected.listAllResources(); + expect(resources.map((r) => r.uri)).toEqual([ + "test://resource_1", + "test://resource_2", + ]); + }); + + it("duplicates before paginating, so a pair straddles a page boundary", async () => { + const started = await start({ + duplicateResourceUris: ["test://resource_1", "test://resource_2"], + maxPageSize: { resources: 2 }, + }); + const connected = await connect(started.url); + + // Two resources at a page size of two: the duplicated copies land on page + // 2, which only holds if duplication runs before the slice. + const firstPage = await connected.listResources(); + expect(firstPage.resources.map((r) => r.uri)).toEqual([ + "test://resource_1", + "test://resource_2", + ]); + expect(firstPage.nextCursor).toBeDefined(); + + const { resources } = await connected.listAllResources(); + expect(resources.map((r) => r.uri)).toEqual([ + "test://resource_1", + "test://resource_2", + "test://resource_1", + "test://resource_2", + ]); + expect(resources.slice(2).map((r) => r.title)).toEqual([ + "resource_1 (duplicate)", + "resource_2 (duplicate)", + ]); + }); + + it("serves the shape the showcase config declares", async () => { + // Covers the JSON → ConfigFile → ServerConfig plumbing, not just the + // in-process option: a config file is how the manual repro is produced. + const configPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../../test-servers/configs/duplicate-resource-uris-http.json", + ); + const resolved = resolveConfig(loadConfig(configPath)); + expect(resolved.duplicateResourceUris).toEqual([ + "test://resource_1", + "test://resource_3", + ]); + + // Let the harness pick the port instead of the config's fixed one, so this + // test can't collide with a manually-running showcase server. + const started = await start({ + resources: resolved.resources, + duplicateResourceUris: resolved.duplicateResourceUris, + }); + const connected = await connect(started.url); + + const { resources } = await connected.listAllResources(); + expect(resources.map((r) => r.uri)).toEqual([ + "test://resource_1", + "test://resource_2", + "test://resource_3", + "test://resource_4", + "test://resource_1", + "test://resource_3", + ]); + + // The whole point of the fixture: filtering by "resource_2" must be able to + // drop every non-matching row, duplicates included. + const matching = resources.filter( + (r) => + r.name.includes("resource_2") || + r.uri.includes("resource_2") || + (r.title?.includes("resource_2") ?? false), + ); + expect(matching).toHaveLength(1); + }); +}); diff --git a/clients/web/src/utils/listRowKey.test.ts b/clients/web/src/utils/listRowKey.test.ts new file mode 100644 index 00000000..675ec954 --- /dev/null +++ b/clients/web/src/utils/listRowKey.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { listRowKey } from "./listRowKey"; + +describe("listRowKey", () => { + it("combines the source index with the identifier", () => { + expect(listRowKey("file:///a.txt", 0)).toBe("0:file:///a.txt"); + }); + + it("distinguishes repeats of the same identifier", () => { + const uri = "ui://hello-world/app.html"; + expect(listRowKey(uri, 0)).not.toBe(listRowKey(uri, 1)); + }); + + it("produces a unique key for every row of a list with repeats", () => { + const uris = ["a", "b", "a", "c", "b", "a"]; + const keys = uris.map((uri, index) => listRowKey(uri, index)); + expect(new Set(keys).size).toBe(uris.length); + }); + + // The index is a prefix, so an identifier that itself looks like a key must + // not be able to impersonate a row at another position. + it("does not collide when the identifier itself looks like a key", () => { + expect(listRowKey("0:x", 1)).not.toBe(listRowKey("x", 10)); + }); +}); diff --git a/clients/web/src/utils/listRowKey.ts b/clients/web/src/utils/listRowKey.ts new file mode 100644 index 00000000..86448257 --- /dev/null +++ b/clients/web/src/utils/listRowKey.ts @@ -0,0 +1,22 @@ +/** + * A stable per-row React key for a list whose natural identifier is not + * guaranteed unique. + * + * MCP list results carry no uniqueness constraint: a server may return the same + * tool name (#1957), the same resource `uri`, or the same `uriTemplate` more + * than once, and a locally-maintained list (roots) can collect a duplicate the + * same way. Keying a row on that identifier alone collides, which React warns + * about on every render and which lets a filtered-out row survive + * reconciliation — the duplicate is rendered once, or dropped, rather than + * shown as the two entries the server actually sent (#2206). + * + * The item's position in the **unfiltered** list disambiguates duplicates and + * stays stable while a search narrows the view, so capture it before filtering. + * + * This is a UI identity only. The wire identity is still the identifier itself + * — a `tools/call` sends the name, a `resources/read` sends the URI — so a key + * must never be sent to a server. + */ +export function listRowKey(id: string, sourceIndex: number): string { + return `${sourceIndex}:${id}`; +} diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index 710df000..9d08d499 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -1,5 +1,6 @@ import type { Tool } from "@modelcontextprotocol/client"; import { declaresAnyFields } from "@inspector/core/json/rootUnion.js"; +import { listRowKey } from "./listRowKey"; /** * Returns the display label for an MCP entity that follows the BaseMetadata @@ -42,9 +43,12 @@ export function hasInputFields(tool: Tool): boolean { * * This is a UI identity only — the wire identity is still `tool.name`, which is * what a `tools/call` must send. + * + * Shares {@link listRowKey} with the resource lists, which have the same defect + * with duplicate URIs (#2206), so the two formats cannot drift apart. */ export function toolRowKey(name: string, sourceIndex: number): string { - return `${sourceIndex}:${name}`; + return listRowKey(name, sourceIndex); } /** diff --git a/docs/test-servers.md b/docs/test-servers.md index 00991b3a..2c043cc4 100644 --- a/docs/test-servers.md +++ b/docs/test-servers.md @@ -44,6 +44,7 @@ as a missing capability rather than an error. | `empty-cursor-http.json` **(legacy era)** | Pagination whose page-two cursor is `""` | [#2220](https://github.com/modelcontextprotocol/inspector/issues/2220) | | `structured-output-http.json` **(legacy era)** | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | | `duplicate-tool-names-http.json` **(legacy era)** | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | +| `duplicate-resource-uris-http.json` **(legacy era)** | A `resources/list` that repeats a resource URI | [#2206](https://github.com/modelcontextprotocol/inspector/issues/2206) | | `nullable-fields-http.json` **(legacy era)** | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | | `root-union-schemas-http.json` **(legacy era)** | Tool schemas whose arguments are a root `anyOf` / `oneOf` | [#2123](https://github.com/modelcontextprotocol/inspector/issues/2123) | | `unportable-schemas-http.json` **(legacy era)** | Tool schemas a real client rejects, flagged in all three clients | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005) | @@ -270,6 +271,12 @@ Connect (default legacy era), open the Tools tab, and type `get` into **Search t The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated. +## Duplicate resource URIs + +`duplicate-resource-uris-http.json` is the `resources/list` counterpart: it serves `resource_1` … `resource_4`, then repeats `test://resource_1` and `test://resource_3` at the end of the list with the same `uri` and a `(duplicate)` title (`duplicateResourceUris`). Unreachable through a preset for the same reason — `registerResource` keys on the URI — and appended rather than adjacent for the same reason as above. + +Connect (default legacy era) and open the Resources tab. With the browser console open, the **URIs** section must list all six rows and log **no** `Encountered two children with the same key` warning; typing `resource_2` into **Search** must narrow it to exactly one row. On the broken build the sidebar keyed rows by `resource.uri` alone, so the warning repeated on every render and a filtered-out row survived reconciliation ([#2206](https://github.com/modelcontextprotocol/inspector/issues/2206)). + ## Nullable arguments `nullable-fields-http.json` serves `record_shipment`, whose four arguments are each declared with Zod's `.nullish()` — "optional **and** explicitly nullable". That compiles to `anyOf: [, { "type": "null" }]`, so the real type (and, for the enum, its `enum` list) sits on a branch rather than at the top level. `get_temp` sits alongside it with a plain, non-nullable `units` enum for comparison. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. diff --git a/test-servers/configs/duplicate-resource-uris-http.json b/test-servers/configs/duplicate-resource-uris-http.json new file mode 100644 index 00000000..4084f3b9 --- /dev/null +++ b/test-servers/configs/duplicate-resource-uris-http.json @@ -0,0 +1,12 @@ +{ + "serverInfo": { + "name": "duplicate-resource-uris", + "version": "1.0.0" + }, + "resources": [{ "preset": "numbered_resources", "params": { "count": 4 } }], + "duplicateResourceUris": ["test://resource_1", "test://resource_3"], + "transport": { + "type": "streamable-http", + "port": 3143 + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index b4338077..617d7acf 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -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, diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index be4878a8..75a37373 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -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. diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 35ec20b9..52eaca8e 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -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