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