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:
commit
849eb08db0
13 changed files with 567 additions and 32 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
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,
|
||||
createFileResourceTemplate,
|
||||
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<ServerConfig>): Promise<TestServerHttp> {
|
||||
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<InspectorClient> {
|
||||
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",
|
||||
]);
|
||||
});
|
||||
|
||||
// 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"],
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
25
clients/web/src/utils/listRowKey.test.ts
Normal file
25
clients/web/src/utils/listRowKey.test.ts
Normal file
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
22
clients/web/src/utils/listRowKey.ts
Normal file
22
clients/web/src/utils/listRowKey.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`, including one no branch of which can be offered | [#2123](https://github.com/modelcontextprotocol/inspector/issues/2123), [#2224](https://github.com/modelcontextprotocol/inspector/issues/2224) |
|
||||
| `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: [<branch>, { "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.
|
||||
|
|
|
|||
12
test-servers/configs/duplicate-resource-uris-http.json
Normal file
12
test-servers/configs/duplicate-resource-uris-http.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -514,6 +514,29 @@ export interface ServerConfig {
|
|||
* ignored.
|
||||
*/
|
||||
duplicateToolNames?: string[];
|
||||
/**
|
||||
* 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
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
duplicateResourceUris?: string[];
|
||||
/**
|
||||
* Replace a registered tool's `inputSchema` / `outputSchema` in `tools/list`
|
||||
* with a **raw** JSON Schema document (#1005).
|
||||
|
|
@ -1410,13 +1433,39 @@ 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. 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
|
||||
? 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 +1507,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,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,13 @@ export interface ConfigFile {
|
|||
* no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957).
|
||||
*/
|
||||
duplicateToolNames?: string[];
|
||||
/**
|
||||
* 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[];
|
||||
/**
|
||||
* Replace a registered tool's advertised `inputSchema`/`outputSchema` with a
|
||||
* raw JSON Schema document — the constructs a Zod-built preset cannot emit.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue