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

Two findings, both about the fallback accepting more than it should.

- The lenient page schema validated pagination and nothing else, so a response
  carrying a malformed entry AND an unrelated top-level violation salvaged: the
  entry was reported and the other violation vanished with the strict error that
  caught it. The page schema is now DERIVED from the method's real result schema
  with only its item array relaxed (`lenientListPageSchema`), so every other
  field — `_meta`, `nextCursor`, anything a future revision adds — is still
  validated. The modern era envelope the raw-wire path bypasses (`resultType`,
  `ttlMs`, `cacheScope`) is checked explicitly for the same reason.

  Salvage is also narrowed to `InvalidResult`: `UnsupportedResultType` means the
  codec doesn't recognize what KIND of frame arrived, so it isn't a list with a
  bad entry and reporting an item-level reason would hide a protocol failure.
  Marking the Protocol entry still applies to both, hence two predicates.

- On every rethrow path the re-walk had already answered the method, so the
  caller's own `markResponseRejected` (in `ManagedListState.refresh`) landed on
  the lenient page that succeeded and left the refused response clean. The
  correlation is restored before rethrowing — deleted when nothing had answered
  before, so the outer mark is a no-op rather than a lie.

`nextCursorOf` / `rawItemsOf` narrow the page's fields instead of casting: the
runtime-keyed `extend()` loses the shape, and asserting it back would be exactly
the unjustified cast the last round removed.

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:22:10 -04:00
commit 573b14e14e
2 changed files with 251 additions and 37 deletions

View file

@ -2,14 +2,18 @@ import { describe, it, expect } from "vitest";
import { z } from "zod/v4";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import {
LenientListPageSchema,
ModernResultEnvelopeSchema,
describeIssues,
isClientDecodeRejection,
isSalvageableRejection,
labelForRawItem,
lenientListPageSchema,
nextCursorOf,
rawItemsOf,
salvageListItems,
summarizeMalformed,
} from "@inspector/core/mcp/listSalvage.js";
import { ListToolsResultSchema } from "@modelcontextprotocol/core";
/**
* Per-item list salvage (#1909).
@ -48,34 +52,102 @@ describe("isClientDecodeRejection", () => {
});
});
describe("LenientListPageSchema", () => {
it("keeps the primitive array unvalidated while typing the cursor", () => {
const parsed = LenientListPageSchema.parse({
resourceTemplates: [{ name: "ok" }, { name: 42 }],
describe("lenientListPageSchema", () => {
const ToolsPageSchema = lenientListPageSchema(ListToolsResultSchema, "tools");
it("leaves the entries raw while still validating every other field", () => {
// The entries are this module's job to check one at a time; everything
// else is the real schema's, so a second violation can't ride along.
const parsed = ToolsPageSchema.parse({
tools: [{ name: "ok" }, { name: 42 }],
nextCursor: "page-2",
});
expect(parsed.nextCursor).toBe("page-2");
expect(rawItemsOf(parsed, "resourceTemplates")).toHaveLength(2);
expect(nextCursorOf(parsed)).toBe("page-2");
expect(rawItemsOf(parsed, "tools")).toHaveLength(2);
});
it("rejects a page whose cursor is malformed", () => {
// The walk itself depends on `nextCursor`, so a bad one is not salvageable
// — the strict error should stand rather than a partial list being kept.
expect(
LenientListPageSchema.safeParse({ tools: [], nextCursor: 7 }).success,
ToolsPageSchema.safeParse({ tools: [], nextCursor: 7 }).success,
).toBe(false);
});
it("rejects a top-level violation that rides along with a bad entry", () => {
// A hand-rolled `{ nextCursor? }` schema would accept this, report the bad
// entry, and silently drop the `_meta` violation with the strict error.
expect(
ToolsPageSchema.safeParse({ tools: [{ name: 42 }], _meta: "nope" })
.success,
).toBe(false);
});
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: [] }), "tools"),
).toEqual([]);
expect(rawItemsOf(ToolsPageSchema.parse({ tools: [] }), "tools")).toEqual(
[],
);
expect(rawItemsOf({}, "tools")).toBeUndefined();
expect(rawItemsOf({ tools: "not-a-list" }, "tools")).toBeUndefined();
expect(rawItemsOf(null, "tools")).toBeUndefined();
});
it("reads no cursor from a non-object page", () => {
expect(nextCursorOf(null)).toBeUndefined();
expect(nextCursorOf({ nextCursor: 7 })).toBeUndefined();
});
});
describe("isSalvageableRejection", () => {
it("salvages an invalid result but not an unrecognized result type", () => {
// `UnsupportedResultType` means the codec doesn't know what KIND of frame
// arrived — not a list with a bad entry, so there is nothing to salvage
// and reporting an item-level reason would hide the real failure.
expect(
rawItemsOf(LenientListPageSchema.parse({ tools: "not-a-list" }), "tools"),
).toBeUndefined();
isSalvageableRejection(new SdkError(SdkErrorCode.InvalidResult, "bad")),
).toBe(true);
expect(
isSalvageableRejection(
new SdkError(SdkErrorCode.UnsupportedResultType, "unknown"),
),
).toBe(false);
// Still marks the Protocol entry, which is why both predicates exist.
expect(
isClientDecodeRejection(
new SdkError(SdkErrorCode.UnsupportedResultType, "unknown"),
),
).toBe(true);
});
});
describe("ModernResultEnvelopeSchema", () => {
it("accepts a complete modern result with its cache hints", () => {
expect(
ModernResultEnvelopeSchema.safeParse({
resultType: "complete",
ttlMs: 0,
cacheScope: "public",
tools: [],
}).success,
).toBe(true);
});
it("rejects a modern result missing the envelope the codec would enforce", () => {
// The raw-wire salvage path goes around that codec, so a response missing
// its cache hints AND carrying a bad entry must not salvage.
expect(
ModernResultEnvelopeSchema.safeParse({
resultType: "complete",
tools: [],
}).success,
).toBe(false);
expect(
ModernResultEnvelopeSchema.safeParse({
resultType: "task",
ttlMs: 0,
cacheScope: "public",
}).success,
).toBe(false);
});
});

View file

@ -565,6 +565,36 @@ describe("InspectorClient list salvage (#1909)", () => {
expect(reported[0]?.label).toBeUndefined();
});
it("leaves the correlation on the original response when it rethrows", async () => {
// On a rethrow the re-walk has already answered the same method, so the
// caller's own `markResponseRejected` would land on the lenient page that
// succeeded. The correlation is restored so the outer mark still finds the
// response that was actually refused.
const server = await startMalformedServer({
resourceTemplates: [{ items: [VALID_TEMPLATE], nextCursor: 42 }],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
const log = new MessageLogState(connected);
await expect(connected.listAllResourceTemplates()).rejects.toThrow();
// Simulates what ManagedListState.refresh() does in its catch.
connected.markResponseRejected("resources/templates/list", "outer mark");
const calls = log
.getMessages()
.filter(
(entry) =>
"method" in entry.message &&
entry.message.method === "resources/templates/list",
);
expect(calls[0]?.clientError).toBe("outer mark");
for (const later of calls.slice(1)) {
expect(later.clientError).toBeUndefined();
}
log.destroy();
});
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