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:
parent
aea3f6d4c2
commit
573b14e14e
2 changed files with 251 additions and 37 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue