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

The visible review was clean; all five findings were in the suppressed block,
two of them real defects.

- The exclusion scan's aggregate offset was wrong twice over: `excluded.length`
  counts only invalid-header tools, and on page one it also folded in the
  current page's valid count. A reported index therefore pointed at a position
  that doesn't exist — worst for an unlabeled entry, whose index is all the user
  has. Replaced with a raw-entry offset advanced by each page's valid + malformed
  counts.

- The scan's own refused response was never marked. It is a distinct exchange
  from the aggregate's, and the lenient re-fetch moves the method correlation
  off it, so its Protocol entry rendered as a clean success the client had in
  fact refused. Marked with the captured id, as `salvageList` already does.

- Three double casts in the test fixture are gone: `ListPage` now models raw
  wire data (`items: unknown`, `nextCursor?: unknown`), which is what a server
  built to emit spec-forbidden shapes actually serves. Declaring the validated
  type and casting malformed values through it asserted the opposite of what
  these tests exist to check. The cursor map narrows to `string` at the one
  place a cursor addresses a page.

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 18:07:12 -04:00
commit aea3f6d4c2

View file

@ -3496,6 +3496,9 @@ export class InspectorClient extends InspectorClientEventTarget {
): Promise<ExcludedTool[]> {
const excluded: ExcludedTool[] = [];
const malformed: MalformedListItem[] = [];
// Raw entries seen so far, across pages — the frame the reported indices
// are relative to (see the mapping below).
let rawOffset = 0;
// 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()) {
@ -3506,10 +3509,15 @@ export class InspectorClient extends InspectorClientEventTarget {
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,
// Offset by the RAW entries of PRIOR pages only. `excluded.length`
// would be wrong twice over: it counts just the invalid-header
// tools, and on page one it would also fold in this page's own
// valid count — putting an unlabeled entry, whose index is all the
// user has to go on, at a position that doesn't exist.
index: entry.index + rawOffset,
})),
);
rawOffset += page.tools.length + page.malformed.length;
for (const tool of page.tools) {
const scan = scanXMcpHeaderDeclarations(tool.inputSchema);
if (!scan.valid) excluded.push({ tool, reason: scan.reason });
@ -3561,6 +3569,13 @@ export class InspectorClient extends InspectorClientEventTarget {
return { ...page, malformed: [] };
} catch (err) {
if (!isClientDecodeRejection(err)) throw err;
// Capture before the re-fetch, for the reason `salvageList` documents:
// this refused response is a distinct exchange from the aggregate's, and
// the re-fetch below moves the method's correlation off it. Without this
// the scan's Protocol entry renders as a clean success even though the
// client refused the result.
const rejectedResponseId =
this.lastAnsweredRequestByMethod.get("tools/list");
const effectiveMeta = this.mergeMeta(metadata);
const params = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
@ -3587,6 +3602,12 @@ export class InspectorClient extends InspectorClientEventTarget {
items,
schema: ToolSchema,
});
if (rejectedResponseId !== undefined && malformed.length > 0) {
this.dispatchTypedEvent("responseRejected", {
id: rejectedResponseId,
reason: summarizeMalformed(malformed),
});
}
return {
tools: valid,
...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),