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