fix: address Copilot round 2 on the list-salvage path (#1909)

All four findings taken (one visible, three in the suppressed block), plus the
`excludedTools` sibling leak the round-1 reply had deferred:

- `rawItemsOf` read a missing or non-array list member as an empty page, so a
  top-level violation (`{ tools: "invalid" }`) on a later page was discarded
  and the call returned a silently truncated list. It now returns `undefined`
  for "not a list at all", and the walk rethrows the original strict error.

- A failure of the re-walk ITSELF (a malformed `nextCursor`, a connection lost
  mid-walk) escaped in place of the original error, whose response is the one
  `rejectedResponseId` was captured for. The walk is now wrapped: the original
  surfaces, the secondary is logged so it stays diagnosable.

- The exclusion scan's lenient fallback discarded its malformed entries despite
  the comment claiming they were reported. Observable when the aggregate is
  served from cache: the salvage never runs, so nothing else reports them. The
  scan now returns them and `refreshExcludedTools` reports them — only ever
  adding, so a clean scan can't clear what the salvage path just wrote.

- An empty-string cursor is a valid cursor, and `cursor ? {cursor} : {}` dropped
  it, re-requesting page one and duplicating its entries until the
  repeated-cursor guard fired. Both walks now test `!== undefined`.

- `excludedTools` had the same session leak fixed for `malformedListItems` last
  round: cleared only on an explicit `disconnect()`, so the `onerror`-with-no-
  `onclose` route carried the previous server's set into the next connection.
  Cleared on the start-clean path too — safe because every `listAllTools`
  recomputes it, so this can only drop a stale set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 17:52:36 -04:00
commit 1889e04135
2 changed files with 229 additions and 62 deletions

View file

@ -1616,15 +1616,23 @@ export class InspectorClient extends InspectorClientEventTarget {
// (#1953).
this.outboundRequestMethods.clear();
this.lastAnsweredRequestByMethod.clear();
// Per-session for the same reason: a report names entries of the PREVIOUS
// Per-session for the same reason: both name entries of the PREVIOUS
// server's list. Cleared here as well as in `disconnect()` because the
// route out that tears down nothing (`onerror` with no `onclose`) would
// otherwise carry it into the next connection — indefinitely, if that
// server never lists the method again (#1909).
// otherwise carry them into the next connection — indefinitely, if that
// server never lists the method again. Safe to clear unconditionally at
// connect: `listAllTools` recomputes the excluded set on every aggregate
// and the salvage report on every list, so this can only drop a stale set,
// never a live one (#1909; the `excludedTools` half is the same latent bug
// from #1632, fixed here rather than left as a known sibling).
if (this.malformedListItems.size > 0) {
this.malformedListItems.clear();
this.dispatchTypedEvent("malformedListItemsChange", []);
}
if (this.excludedTools.length > 0) {
this.excludedTools = [];
this.dispatchTypedEvent("excludedToolsChange", []);
}
for (const [, controller] of this.taskInputAbortControllers) {
controller.abort(new Error("Connection ended"));
}
@ -3381,50 +3389,75 @@ export class InspectorClient extends InspectorClientEventTarget {
const malformed: MalformedListItem[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
do {
const effectiveMeta = this.mergeMeta(metadata);
const params = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
// The modern (2026-07-28) codec validates a result against the SPEC
// schema for the method before the caller's schema is consulted, so a
// lenient `client.request` is rejected exactly as the strict one was —
// there is no salvage without going around it. The raw-wire channel that
// exists for the modern `tasks/*` methods does precisely that: the frame
// goes straight through the transport (still logged for the Protocol /
// Network tabs) and only the caller's schema is applied. Legacy keeps the
// ordinary SDK path, which honors request options and `_meta` for us.
const page = await this.invokeMcpClient(
() =>
this.isModernEra()
? this.rawWireRequest(
method,
this.withModernEnvelope(params),
LenientListPageSchema,
)
: this.client!.request(
{ method, params },
LenientListPageSchema,
this.getRequestOptions(metadata?.progressToken),
),
{ method },
);
const salvaged = salvageListItems({
method,
items: rawItemsOf(page, itemsKey),
schema: itemSchema,
startIndex: valid.length + malformed.length,
});
valid.push(...salvaged.valid);
malformed.push(...salvaged.malformed);
cursor = page.nextCursor;
if (cursor !== undefined) {
/* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */
if (seenCursors.has(cursor)) break;
seenCursors.add(cursor);
try {
do {
const effectiveMeta = this.mergeMeta(metadata);
const params = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
// `!== undefined`, not truthiness: "" is a valid cursor, and dropping it
// would re-request page one and duplicate its entries until the
// repeated-cursor guard below stopped the walk.
...(cursor !== undefined && { cursor }),
};
// The modern (2026-07-28) codec validates a result against the SPEC
// schema for the method before the caller's schema is consulted, so a
// lenient `client.request` is rejected exactly as the strict one was —
// there is no salvage without going around it. The raw-wire channel that
// exists for the modern `tasks/*` methods does precisely that: the frame
// goes straight through the transport (still logged for the Protocol /
// Network tabs) and only the caller's schema is applied. Legacy keeps the
// ordinary SDK path, which honors request options and `_meta` for us.
const page = await this.invokeMcpClient(
() =>
this.isModernEra()
? this.rawWireRequest(
method,
this.withModernEnvelope(params),
LenientListPageSchema,
)
: this.client!.request(
{ method, params },
LenientListPageSchema,
this.getRequestOptions(metadata?.progressToken),
),
{ method },
);
const items = rawItemsOf(page, itemsKey);
// The page's list member is missing or isn't a list: a top-level
// violation, not a per-item one. Reading it as "no entries" would return
// a silently truncated list and discard the strict error that was right
// about it.
if (items === undefined) throw err;
const salvaged = salvageListItems({
method,
items,
schema: itemSchema,
startIndex: valid.length + malformed.length,
});
valid.push(...salvaged.valid);
malformed.push(...salvaged.malformed);
cursor = page.nextCursor;
if (cursor !== undefined) {
/* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */
if (seenCursors.has(cursor)) break;
seenCursors.add(cursor);
}
} while (cursor !== undefined);
} catch (walkErr) {
// The re-walk itself failed — its own decode rejection (a malformed
// `nextCursor`, an unsalvageable page), or the connection going away
// mid-walk. Either way the ORIGINAL error is the one to surface: it is
// what the caller's list actually failed on, and `rejectedResponseId`
// above was captured for that response, not this one. The secondary
// failure is logged so it stays diagnosable rather than vanishing.
if (walkErr !== err) {
this.logger.warn(
{ err: walkErr, method },
"Salvage re-walk failed; surfacing the original list error",
);
}
} while (cursor !== undefined);
throw err;
}
// Nothing per-item was wrong, so the strict rejection was about something
// this fallback cannot explain — surface it rather than hide it.
@ -3462,6 +3495,7 @@ export class InspectorClient extends InspectorClientEventTarget {
metadata?: Record<string, string>,
): Promise<ExcludedTool[]> {
const excluded: ExcludedTool[] = [];
const malformed: MalformedListItem[] = [];
// Gated to connections that actually exclude; otherwise this is a pure
// no-op (no round trip). The raw `listTools` below guards the connection.
if (this.excludesInvalidXMcpHeaderTools()) {
@ -3469,6 +3503,13 @@ export class InspectorClient extends InspectorClientEventTarget {
let cursor: string | undefined;
do {
const page = await this.listToolsForScan(cursor, metadata);
malformed.push(
...page.malformed.map((entry) => ({
...entry,
// Index against the aggregate this walk is building, not the page.
index: entry.index + excluded.length + page.tools.length,
})),
);
for (const tool of page.tools) {
const scan = scanXMcpHeaderDeclarations(tool.inputSchema);
if (!scan.valid) excluded.push({ tool, reason: scan.reason });
@ -3481,6 +3522,15 @@ export class InspectorClient extends InspectorClientEventTarget {
}
} while (cursor !== undefined);
}
// Report what the scan itself had to drop. This walk is deliberately
// un-cached, so it can see a malformed tool the `listAllTools` aggregate
// never did — that aggregate may have been served from the response cache,
// in which case its salvage never ran and cleared the report instead. Only
// ever ADDS: a scan that finds nothing must not clear a report the salvage
// path just wrote from its own walk of the same list.
if (malformed.length > 0) {
this.setMalformedListItems("tools/list", malformed);
}
this.excludedTools = excluded;
this.dispatchTypedEvent("excludedToolsChange", excluded);
return excluded;
@ -3501,15 +3551,21 @@ export class InspectorClient extends InspectorClientEventTarget {
private async listToolsForScan(
cursor: string | undefined,
metadata: Record<string, string> | undefined,
): Promise<{ tools: Tool[]; nextCursor?: string }> {
): Promise<{
tools: Tool[];
nextCursor?: string;
malformed: MalformedListItem[];
}> {
try {
return await this.listTools(cursor, metadata);
const page = await this.listTools(cursor, metadata);
return { ...page, malformed: [] };
} catch (err) {
if (!isClientDecodeRejection(err)) throw err;
const effectiveMeta = this.mergeMeta(metadata);
const params = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
// See `salvageList`: "" is a valid cursor.
...(cursor !== undefined && { cursor }),
};
const page = this.isModernEra()
? await this.rawWireRequest(
@ -3522,14 +3578,19 @@ export class InspectorClient extends InspectorClientEventTarget {
LenientListPageSchema,
this.getRequestOptions(metadata?.progressToken),
);
const { valid } = salvageListItems({
const items = rawItemsOf(page, "tools");
// Not a list at all — a top-level violation the per-item pass can't
// explain, so the strict error stands (mirrors `salvageList`).
if (items === undefined) throw err;
const { valid, malformed } = salvageListItems({
method: "tools/list",
items: rawItemsOf(page, "tools"),
items,
schema: ToolSchema,
});
return {
tools: valid,
...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),
malformed,
};
}
}

View file

@ -93,10 +93,22 @@ export const LenientListPageSchema = z.looseObject({
export type LenientListPage = z.infer<typeof LenientListPageSchema>;
/** Read the primitive array out of a leniently-parsed page. */
export function rawItemsOf(page: LenientListPage, itemsKey: string): unknown[] {
/**
* Read the primitive array out of a leniently-parsed page.
*
* Returns `undefined` — NOT an empty array — when the member is missing or is
* not an array, because those are different facts. An empty page is a normal
* server answer; a page whose `tools` is the string `"invalid"` is a top-level
* schema violation this fallback cannot explain, and reading it as "no entries"
* would let the caller return a silently truncated list while the strict error
* that was correct about it is discarded.
*/
export function rawItemsOf(
page: LenientListPage,
itemsKey: string,
): unknown[] | undefined {
const value = (page as Record<string, unknown>)[itemsKey];
return Array.isArray(value) ? value : [];
return Array.isArray(value) ? value : undefined;
}
/**