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:
parent
54c6088114
commit
1889e04135
3 changed files with 229 additions and 62 deletions
|
|
@ -66,14 +66,16 @@ describe("LenientListPageSchema", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads an absent or non-array items key as no entries", () => {
|
||||
expect(rawItemsOf({}, "tools")).toEqual([]);
|
||||
it("distinguishes an empty page from one that is not a list at all", () => {
|
||||
// Reading either as "no entries" would let a top-level violation pass as a
|
||||
// silently truncated list.
|
||||
expect(
|
||||
rawItemsOf(
|
||||
LenientListPageSchema.parse({ tools: "not-a-list" }) as never,
|
||||
"tools",
|
||||
),
|
||||
rawItemsOf(LenientListPageSchema.parse({ tools: [] }), "tools"),
|
||||
).toEqual([]);
|
||||
expect(rawItemsOf({}, "tools")).toBeUndefined();
|
||||
expect(
|
||||
rawItemsOf(LenientListPageSchema.parse({ tools: "not-a-list" }), "tools"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,28 @@ describe("excluded tools (SEP-2243 x-mcp-header)", () => {
|
|||
expect(excluded.map((x) => x.tool.name)).toEqual(["invalid_header_tool"]);
|
||||
});
|
||||
|
||||
it("does not carry an excluded set into the next connection (#1909)", async () => {
|
||||
// `resetSessionState()` is the start-clean path, and it is the ONLY one the
|
||||
// route out that tears down nothing (`onerror` with no `onclose`) reaches.
|
||||
// A set left behind names tools of the PREVIOUS server — and would persist
|
||||
// indefinitely against a server that never lists tools again.
|
||||
const started = await start({});
|
||||
const connected = await connect(started.url, "modern");
|
||||
await connected.listAllTools();
|
||||
expect(connected.getExcludedTools()).toHaveLength(1);
|
||||
|
||||
const events: number[] = [];
|
||||
connected.addEventListener("excludedToolsChange", (e) => {
|
||||
events.push(e.detail.length);
|
||||
});
|
||||
// Reconnecting runs the start-clean reset; assert before anything re-lists.
|
||||
await connected.disconnect();
|
||||
await connected.connect();
|
||||
|
||||
expect(connected.getExcludedTools()).toEqual([]);
|
||||
expect(events).toContain(0);
|
||||
});
|
||||
|
||||
it("reports no excluded tools on a legacy connection (no exclusion there)", async () => {
|
||||
const started = await start(undefined);
|
||||
const connected = await connect(started.url, "legacy");
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ function startMalformedServer(
|
|||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
calls: string[];
|
||||
/** Every `cursor` param the server was sent, in order. */
|
||||
cursors: (string | undefined)[];
|
||||
/** Swap what the server serves next, to model a server that got fixed. */
|
||||
setPages: (next: {
|
||||
resourceTemplates?: ListPage[];
|
||||
|
|
@ -60,10 +62,21 @@ function startMalformedServer(
|
|||
const calls: string[] = [];
|
||||
let pages = initialPages;
|
||||
|
||||
const cursors: (string | undefined)[] = [];
|
||||
// Cursor string -> the page index it addresses, learned as pages are served.
|
||||
// Modeling it this way (rather than parsing the cursor as a number) is what
|
||||
// makes an EMPTY-STRING cursor meaningful: a client that drops it sends no
|
||||
// cursor at all, lands back on page one, and duplicates its entries.
|
||||
const cursorToIndex = new Map<string, number>();
|
||||
const pageFor = (key: "resourceTemplates" | "tools", cursor?: string) => {
|
||||
cursors.push(cursor);
|
||||
const configured = pages[key] ?? [];
|
||||
const index = cursor === undefined ? 0 : Number(cursor);
|
||||
return configured[index] ?? { items: [] };
|
||||
const index = cursor === undefined ? 0 : (cursorToIndex.get(cursor) ?? 0);
|
||||
const page = configured[index] ?? { items: [] };
|
||||
if (page.nextCursor !== undefined) {
|
||||
cursorToIndex.set(page.nextCursor, index + 1);
|
||||
}
|
||||
return page;
|
||||
};
|
||||
|
||||
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
|
|
@ -160,6 +173,7 @@ function startMalformedServer(
|
|||
resolve({
|
||||
url: `http://127.0.0.1:${port}/mcp`,
|
||||
calls,
|
||||
cursors,
|
||||
setPages: (next) => {
|
||||
pages = next;
|
||||
},
|
||||
|
|
@ -414,6 +428,62 @@ describe("InspectorClient list salvage (#1909)", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("surfaces the ORIGINAL error when the re-walk itself fails", async () => {
|
||||
// A malformed `nextCursor` fails the lenient page schema too, so the walk
|
||||
// throws its own error. The original is what the caller's list actually
|
||||
// failed on — and what `rejectedResponseId` was captured for — so that is
|
||||
// the one that must surface.
|
||||
const server = await startMalformedServer({
|
||||
resourceTemplates: [
|
||||
{
|
||||
items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE],
|
||||
nextCursor: 42 as unknown as string,
|
||||
},
|
||||
],
|
||||
});
|
||||
stopServer = server.stop;
|
||||
const connected = await connectTo(server.url);
|
||||
|
||||
await expect(connected.listAllResourceTemplates()).rejects.toThrow(
|
||||
/annotations/,
|
||||
);
|
||||
expect(connected.getMalformedListItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the strict error when a page is not a list at all", async () => {
|
||||
// `{ resourceTemplates: "nope" }` is a top-level violation the per-item
|
||||
// pass cannot explain; treating it as an empty page would return a
|
||||
// silently truncated list and discard the error that was right about it.
|
||||
const server = await startMalformedServer({
|
||||
resourceTemplates: [{ items: "nope" as unknown as unknown[] }],
|
||||
});
|
||||
stopServer = server.stop;
|
||||
const connected = await connectTo(server.url);
|
||||
|
||||
await expect(connected.listAllResourceTemplates()).rejects.toThrow();
|
||||
expect(connected.getMalformedListItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends an empty-string cursor rather than re-fetching page one", async () => {
|
||||
// "" is a valid cursor. A truthiness check would drop it, re-request page
|
||||
// one, and duplicate its entries until the repeated-cursor guard fired.
|
||||
const server = await startMalformedServer({
|
||||
resourceTemplates: [
|
||||
{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE], nextCursor: "" },
|
||||
{ items: [{ ...VALID_TEMPLATE, name: "page_two" }] },
|
||||
],
|
||||
});
|
||||
stopServer = server.stop;
|
||||
const connected = await connectTo(server.url);
|
||||
|
||||
const { resourceTemplates } = await connected.listAllResourceTemplates();
|
||||
expect(resourceTemplates.map((t) => t.name)).toEqual([
|
||||
"full_annotations",
|
||||
"page_two",
|
||||
]);
|
||||
expect(server.cursors).toContain("");
|
||||
});
|
||||
|
||||
it("rethrows when the rejection is not about any single entry", async () => {
|
||||
// Every entry validates; the result is bad for another reason (a cursor of
|
||||
// the wrong type). There is no per-item story, so the original error must
|
||||
|
|
|
|||
Loading…
Reference in a new issue