fix: bound the salvage walks and mark the scan's refused response (#1909)

Copilot round 6.

Bound both fallback walks with LIST_MAX_PAGES, which is now also passed to
the SDK client as `listMaxPages` so the strict aggregate and its fallback
share one value by construction. A server emitting endlessly UNIQUE cursors
never trips the repeated-cursor guard, so the salvage re-walk and the
SEP-2243 exclusion scan would page and accumulate without bound. Hitting the
cap aborts rather than returning a truncated list as a complete one.

Mark the exclusion scan's refused response on every fallback-failure path.
`salvageList` can rethrow bare because ManagedListState marks from the error
it receives; the scan has no such backstop — listAllTools catches and logs a
failing scan by design — so an unmarked rethrow left a response the client
demonstrably refused rendering as a clean exchange.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 19:33:27 -04:00
commit 67972adc4e
2 changed files with 245 additions and 44 deletions

View file

@ -2,8 +2,10 @@ import { describe, it, expect } from "vitest";
import { z } from "zod/v4";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import {
LIST_MAX_PAGES,
ModernResultEnvelopeSchema,
describeIssues,
listPaginationExceeded,
isClientDecodeRejection,
isSalvageableRejection,
labelForRawItem,
@ -317,4 +319,21 @@ describe("summarizeMalformed", () => {
expect(summary).toContain("index 0");
expect(summary).toContain("(+1 more)");
});
describe("the page bound", () => {
it("matches the cap the SDK client is configured with", () => {
// The value is passed to the SDK as `listMaxPages`, so the strict
// aggregate and the fallback walks are bounded by the same number.
expect(LIST_MAX_PAGES).toBe(64);
});
it("names the method and the cap it hit", () => {
// The walk's failure is logged rather than surfaced (the original
// validation error is what the caller sees), so the message is the only
// record of why salvage gave up.
const error = listPaginationExceeded("resources/templates/list");
expect(error.message).toContain("resources/templates/list");
expect(error.message).toContain("64 pages");
});
});
});

View file

@ -9,6 +9,7 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { MessageLogState } from "@inspector/core/mcp/state/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
import { LIST_MAX_PAGES } from "@inspector/core/mcp/listSalvage.js";
/**
* Per-item list salvage against a real connection (#1909).
@ -608,4 +609,91 @@ describe("InspectorClient list salvage (#1909)", () => {
await expect(connected.listAllResourceTemplates()).rejects.toThrow();
expect(connected.getMalformedListItems()).toEqual([]);
});
it("stops the salvage walk at the page cap instead of paging forever", async () => {
// A server whose `nextCursor` never converges but never REPEATS slips the
// repeated-cursor guard: every cursor is fresh, so the walk had no reason
// to stop and would page (and accumulate) without bound. The bound is the
// same `LIST_MAX_PAGES` the SDK aggregate is configured with.
const server = await startMalformedServer({
resourceTemplates: Array.from(
{ length: LIST_MAX_PAGES + 40 },
(_, i) => ({
items: [PHP_EMPTY_ANNOTATIONS],
nextCursor: `cursor-${i}`,
}),
),
});
stopServer = server.stop;
const connected = await connectTo(server.url);
// Truncation is not salvage: with entries still unfetched, returning what
// we have would present a partial list as complete, so the original
// validation error stands.
await expect(connected.listAllResourceTemplates()).rejects.toThrow();
expect(connected.getMalformedListItems()).toEqual([]);
const listCalls = server.calls.filter(
(method) => method === "resources/templates/list",
);
// One strict aggregate attempt (rejected on page one), then a salvage walk
// bounded at exactly the cap. Without the bound this runs to 104 pages —
// and against a truly endless server, forever.
expect(listCalls).toHaveLength(1 + LIST_MAX_PAGES);
});
it("stops the exclusion scan at the page cap too", async () => {
// The SEP-2243 scan walks pages of its own, outside the SDK aggregate's
// cap, so it needs the same bound. Driven directly because a non-converging
// list trips `listAllTools`'s salvage walk first.
const server = await startMalformedServer(
{
tools: Array.from({ length: LIST_MAX_PAGES + 40 }, (_, i) => ({
items: [{ name: `tool_${i}`, inputSchema: { type: "object" } }],
nextCursor: `cursor-${i}`,
})),
},
true,
);
stopServer = server.stop;
const connected = await connectTo(server.url, "modern");
await expect(connected.refreshExcludedTools()).rejects.toThrow(
/exceeded 64 pages/,
);
expect(
server.calls.filter((method) => method === "tools/list"),
).toHaveLength(LIST_MAX_PAGES);
});
it("marks the scan's refused response when its own fallback fails", async () => {
// `tools` is not a list at all, so the scan's lenient re-fetch cannot
// explain the rejection and rethrows. `listAllTools` catches a failing scan
// by design (it must never fail the tools list), so nothing downstream
// would mark the response the client demonstrably refused — it would render
// as a clean exchange.
const server = await startMalformedServer(
{ tools: [{ items: "not-a-list" }] },
true,
);
stopServer = server.stop;
const connected = await connectTo(server.url, "modern");
const log = new MessageLogState(connected);
await expect(connected.refreshExcludedTools()).rejects.toThrow();
const marked = log
.getMessages()
.filter(
(entry) =>
"method" in entry.message &&
entry.message.method === "tools/list" &&
entry.clientError !== undefined,
);
// The mark carries the ORIGINAL decode error: the fallback's own failure is
// a different event from the response being marked.
expect(marked).toHaveLength(1);
expect(marked[0]?.clientError).toBeTruthy();
log.destroy();
});
});