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:
parent
23fe62ec5c
commit
67972adc4e
2 changed files with 245 additions and 44 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue