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
4 changed files with 567 additions and 32 deletions

View file

@ -452,4 +452,135 @@ describe("ResourceControls", () => {
renderWithMantine(<ResourceControls {...baseProps} />);
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(
<ResourceControls
{...baseProps}
resources={duplicateUriResources}
templates={duplicateTemplates}
subscriptions={duplicateSubscriptions}
/>,
);
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(
<ControlledResourceControls resources={duplicateUriResources} />,
);
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(
<ControlledResourceControls
resources={[]}
subscriptions={[]}
templates={duplicateTemplates}
/>,
);
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(
<ResourceControls
{...baseProps}
resources={[]}
templates={[]}
subscriptions={duplicateSubscriptions}
/>,
);
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(
<ControlledResourceControls
resources={[]}
templates={[]}
subscriptions={duplicateSubscriptions}
/>,
);
await user.type(screen.getByPlaceholderText("Search..."), "beta");
expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
expect(
screen.getAllByRole("button", { name: "Unsubscribe" }),
).toHaveLength(1);
});
});
});

View file

@ -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({
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredResources.map((resource) => (
{filteredResources.map(({ resource, key }) => (
<ResourceListItem
key={resource.uri}
key={key}
resource={resource}
selected={resource.uri === selectedUri}
onClick={() => {
@ -338,9 +366,9 @@ export function ResourceControls({
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
{filteredTemplates.map((template) => (
{filteredTemplates.map(({ template, key }) => (
<ResourceListItem
key={template.uriTemplate}
key={key}
resource={template}
selected={template.uriTemplate === selectedTemplateUri}
onClick={() => {
@ -381,9 +409,9 @@ export function ResourceControls({
{NEVER_ACKNOWLEDGED_SUBSCRIPTION_MESSAGE}
</StreamNotice>
)}
{filteredSubscriptions.map((sub) => (
{filteredSubscriptions.map(({ subscription: sub, key }) => (
<ResourceSubscribedItem
key={sub.resource.uri}
key={key}
subscription={sub}
onUnsubscribe={() =>
onUnsubscribeResource(sub.resource.uri)

View file

@ -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(
<RootsTable
{...baseProps}
roots={[
{ name: "First", uri: "file:///dupe" },
{ name: "Second", uri: "file:///dupe" },
]}
/>,
);
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(
<RootsTable

View file

@ -12,6 +12,7 @@ import {
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type { Root } from "@modelcontextprotocol/client";
import { listRowKey } from "../../../utils/listRowKey";
export type RootDraft = { name: string; uri: string };
@ -62,8 +63,10 @@ export function RootsTable({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{roots.map((root) => (
<Table.Tr key={root.uri}>
{/* 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) => (
<Table.Tr key={listRowKey(root.uri, index)}>
<Table.Td>{root.name}</Table.Td>
<Table.Td>{root.uri}</Table.Td>
<Table.Td>