fix: duplicate any listed resource URI, not only a registered one
Copilot on #2281: the option's contract said an unregistered URI is ignored, but `withDuplicateResources` runs on the assembled list, which also carries whatever a resource template's `list` callback contributed — so a template-listed URI was duplicated in contradiction of its own doc. Broadened rather than restricted, because scoping it to `registeredResources` would leave half the surface unreproducible: both kinds of entry land in the same Resources sidebar list and collide on the same React key. The contract now says so on `ServerConfig.duplicateResourceUris` and on the config-file mirror, and an integration case covers a template-listed URI — it fails if the override is narrowed to `state.registeredResources`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yVgAJz4wpkS4mkGk7DQnp Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
3153f0305a
commit
339c5f4e27
3 changed files with 40 additions and 7 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
type TestServerHttp,
|
||||
createTestServerInfo,
|
||||
createNumberedResources,
|
||||
createFileResourceTemplate,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
|
@ -107,6 +108,28 @@ describe("duplicate resource URIs in resources/list (#2206)", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
// The option matches the assembled list, not `state.registeredResources`, so
|
||||
// a URI a resource template contributed is duplicated too (Copilot). Both
|
||||
// kinds land in the same Resources sidebar list and collide on the same React
|
||||
// key, so the fixture has to be able to reproduce either.
|
||||
it("duplicates a URI a resource template listed", async () => {
|
||||
const started = await start({
|
||||
resources: [],
|
||||
resourceTemplates: [
|
||||
createFileResourceTemplate(undefined, () => ["file:///notes.md"]),
|
||||
],
|
||||
duplicateResourceUris: ["file:///notes.md"],
|
||||
});
|
||||
const connected = await connect(started.url);
|
||||
|
||||
const { resources } = await connected.listAllResources();
|
||||
expect(resources.map((r) => r.uri)).toEqual([
|
||||
"file:///notes.md",
|
||||
"file:///notes.md",
|
||||
]);
|
||||
expect(resources.at(-1)?.title).toBe("file:///notes.md (duplicate)");
|
||||
});
|
||||
|
||||
it("ignores a URI that is not registered", async () => {
|
||||
const started = await start({
|
||||
duplicateResourceUris: ["test://not_a_resource"],
|
||||
|
|
|
|||
|
|
@ -515,8 +515,8 @@ export interface ServerConfig {
|
|||
*/
|
||||
duplicateToolNames?: string[];
|
||||
/**
|
||||
* URIs of registered resources to emit **twice** in `resources/list` (same
|
||||
* `uri`, the second's title marked "(duplicate)").
|
||||
* URIs to emit **twice** in `resources/list` (same `uri`, the second's title
|
||||
* marked "(duplicate)").
|
||||
*
|
||||
* The `resources/list` analogue of {@link duplicateToolNames}, and
|
||||
* unreachable the same way: `registerResource` keys on the URI, so no preset
|
||||
|
|
@ -524,10 +524,17 @@ export interface ServerConfig {
|
|||
* concatenated — and the Inspector has to render that faithfully rather than
|
||||
* collide on the React key (#2206).
|
||||
*
|
||||
* Matched against **everything the list actually carries**, which is the
|
||||
* static registrations *and* whatever a resource template's `list` callback
|
||||
* contributes — not `state.registeredResources` alone (Copilot). Both land in
|
||||
* the same Resources sidebar list and collide on the same React key, so
|
||||
* scoping this to static registrations only would leave half the surface
|
||||
* unreproducible. A URI that appears in neither is ignored.
|
||||
*
|
||||
* The copies go **after** the whole list rather than beside their twin, for
|
||||
* the reason spelled out on `duplicateToolNames`: a head-adjacent pair
|
||||
* happens to survive reconciliation, so only a separated pair exposes the
|
||||
* defect. A URI that isn't registered is ignored.
|
||||
* defect.
|
||||
*/
|
||||
duplicateResourceUris?: string[];
|
||||
/**
|
||||
|
|
@ -1427,8 +1434,10 @@ export function createMcpServer(config: ServerConfig): McpServer {
|
|||
}
|
||||
|
||||
// Emit each named resource a second time, same `uri`, title marked so the two
|
||||
// rows are told apart on screen. See ServerConfig.duplicateResourceUris
|
||||
// (#2206) — and the note there on why the copies are appended.
|
||||
// rows are told apart on screen. Applied to the assembled list, so a URI a
|
||||
// resource template listed is duplicated exactly as a statically-registered
|
||||
// one is. See ServerConfig.duplicateResourceUris (#2206) — and the note there
|
||||
// on why the copies are appended.
|
||||
const duplicateResourceUris = new Set(config.duplicateResourceUris ?? []);
|
||||
const withDuplicateResources = (resources: Resource[]): Resource[] =>
|
||||
duplicateResourceUris.size === 0
|
||||
|
|
|
|||
|
|
@ -93,8 +93,9 @@ export interface ConfigFile {
|
|||
*/
|
||||
duplicateToolNames?: string[];
|
||||
/**
|
||||
* URIs of registered resources to emit **twice** in `resources/list` (same
|
||||
* `uri`, the second's title marked "(duplicate)"). See
|
||||
* URIs to emit **twice** in `resources/list` (same `uri`, the second's title
|
||||
* marked "(duplicate)") — matched against the assembled list, so a
|
||||
* template-listed URI counts as well as a statically-registered one. See
|
||||
* {@link ServerConfig.duplicateResourceUris} (#2206).
|
||||
*/
|
||||
duplicateResourceUris?: string[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue