fix: salvage a list result's valid entries when one is malformed (#1909)
The SDK validates a list result as a whole, so a single non-conforming entry
rejected the entire response and the panel showed "Couldn't load resources"
with zero items — the exact opposite of what a debugging tool should do with a
server that is 90% right. Reported against a PHP server whose empty
`annotations` object reaches the wire as `[]` rather than `{}`.
`listAll*` now falls back to a lenient re-walk when — and only when — the SDK
rejects a result it received (`InvalidResult` / `UnsupportedResultType`), keeps
the entries that validate, and reports the rest. A conforming server takes the
strict, cache-aware path exactly as before.
Salvaging is not accepting: the dropped entries are named with their reason in
a warning above the list, and the Protocol entry is still marked rejected, so
the wire truth survives. When every entry validates, the original error is
rethrown rather than swallowed — the rejection was about something the
per-item pass cannot explain.
`isClientDecodeRejection` moves to the new module and is now shared with
managedListState, which had the only copy.
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
ebeaa142df
commit
fe5b622fd1
1 changed files with 1402 additions and 64 deletions
232
clients/web/src/test/core/mcp/listSalvage.test.ts
Normal file
232
clients/web/src/test/core/mcp/listSalvage.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
LenientListPageSchema,
|
||||
describeIssues,
|
||||
isClientDecodeRejection,
|
||||
labelForRawItem,
|
||||
rawItemsOf,
|
||||
salvageListItems,
|
||||
summarizeMalformed,
|
||||
} from "@inspector/core/mcp/listSalvage.js";
|
||||
|
||||
/**
|
||||
* Per-item list salvage (#1909).
|
||||
*
|
||||
* These cover the pure half — deciding what is salvageable and how it is
|
||||
* described. The client-side half (fall back only on a decode rejection, keep
|
||||
* the strict path otherwise) is driven end to end against a real malformed
|
||||
* server in `src/test/integration/mcp/inspectorClient-malformed-list.test.ts`.
|
||||
*/
|
||||
|
||||
const ItemSchema = z.object({
|
||||
name: z.string(),
|
||||
annotations: z.object({ priority: z.number().optional() }).optional(),
|
||||
});
|
||||
|
||||
describe("isClientDecodeRejection", () => {
|
||||
it("is true for a result the client received and refused", () => {
|
||||
const err = new SdkError(SdkErrorCode.InvalidResult, "bad result");
|
||||
expect(isClientDecodeRejection(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for a result type the codec cannot handle", () => {
|
||||
const err = new SdkError(SdkErrorCode.UnsupportedResultType, "unknown");
|
||||
expect(isClientDecodeRejection(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a failure that produced no result to salvage", () => {
|
||||
// A transport drop / timeout: re-listing would just fail again, and there
|
||||
// is no response frame to pick entries out of.
|
||||
expect(isClientDecodeRejection(new Error("socket hang up"))).toBe(false);
|
||||
expect(
|
||||
isClientDecodeRejection(
|
||||
new SdkError(SdkErrorCode.RequestTimeout, "timed out"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LenientListPageSchema", () => {
|
||||
it("keeps the primitive array unvalidated while typing the cursor", () => {
|
||||
const parsed = LenientListPageSchema.parse({
|
||||
resourceTemplates: [{ name: "ok" }, { name: 42 }],
|
||||
nextCursor: "page-2",
|
||||
});
|
||||
expect(parsed.nextCursor).toBe("page-2");
|
||||
expect(rawItemsOf(parsed, "resourceTemplates")).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,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads an absent or non-array items key as no entries", () => {
|
||||
expect(rawItemsOf({}, "tools")).toEqual([]);
|
||||
expect(
|
||||
rawItemsOf(
|
||||
LenientListPageSchema.parse({ tools: "not-a-list" }) as never,
|
||||
"tools",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("labelForRawItem", () => {
|
||||
it("prefers a name, then a uriTemplate, uri, or title", () => {
|
||||
expect(labelForRawItem({ name: "get_weather" })).toBe("get_weather");
|
||||
expect(labelForRawItem({ uriTemplate: "file:///{path}" })).toBe(
|
||||
"file:///{path}",
|
||||
);
|
||||
expect(labelForRawItem({ uri: "test://one" })).toBe("test://one");
|
||||
expect(labelForRawItem({ title: "Titled" })).toBe("Titled");
|
||||
});
|
||||
|
||||
it("has no label for an entry too broken to carry one", () => {
|
||||
expect(labelForRawItem(null)).toBeUndefined();
|
||||
expect(labelForRawItem("just a string")).toBeUndefined();
|
||||
expect(labelForRawItem(7)).toBeUndefined();
|
||||
expect(labelForRawItem({ name: "" })).toBeUndefined();
|
||||
expect(labelForRawItem({ name: 42 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeIssues", () => {
|
||||
it("names the offending field so the reader doesn't have to hunt", () => {
|
||||
const result = ItemSchema.safeParse({ name: "t", annotations: [] });
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(describeIssues(result.error)).toMatch(
|
||||
/^annotations: .*expected object/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("names the expected type even when zod's message omits it", () => {
|
||||
// The browser build reports a bare "Invalid input" where Node spells out
|
||||
// "expected object, received array" — without the expected type the warning
|
||||
// says something is wrong but not what would be right.
|
||||
const terse = new z.ZodError([
|
||||
{
|
||||
code: "invalid_type",
|
||||
expected: "object",
|
||||
path: ["annotations"],
|
||||
message: "Invalid input",
|
||||
// Double cast: the terse-message shape is what zod's BROWSER build
|
||||
// emits, and no parse run under Node can produce it — the issue has to
|
||||
// be hand-built, and `$ZodIssue`'s union doesn't accept a literal.
|
||||
} as unknown as z.core.$ZodIssue,
|
||||
]);
|
||||
expect(describeIssues(terse)).toBe(
|
||||
"annotations: Invalid input (expected object)",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not repeat an expected type the message already carries", () => {
|
||||
const result = ItemSchema.safeParse({ name: "t", annotations: [] });
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
const described = describeIssues(result.error);
|
||||
expect(described.match(/expected object/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("omits the path when the failure is the entry itself", () => {
|
||||
const result = ItemSchema.safeParse("not an object");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
const described = describeIssues(result.error);
|
||||
expect(described).not.toMatch(/^:/);
|
||||
expect(described).toMatch(/expected object/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("salvageListItems", () => {
|
||||
it("keeps the valid entries and reports only the broken one", () => {
|
||||
const { valid, malformed } = salvageListItems({
|
||||
method: "resources/templates/list",
|
||||
items: [
|
||||
{ name: "empty_annotations", annotations: {} },
|
||||
{ name: "array_annotations", annotations: [] },
|
||||
{ name: "full_annotations", annotations: { priority: 0.8 } },
|
||||
],
|
||||
schema: ItemSchema,
|
||||
});
|
||||
|
||||
expect(valid.map((item) => item.name)).toEqual([
|
||||
"empty_annotations",
|
||||
"full_annotations",
|
||||
]);
|
||||
expect(malformed).toEqual([
|
||||
{
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: expect.stringMatching(/^annotations: /),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an unlabelable entry by its position", () => {
|
||||
const { valid, malformed } = salvageListItems({
|
||||
method: "tools/list",
|
||||
items: [null],
|
||||
schema: ItemSchema,
|
||||
});
|
||||
expect(valid).toEqual([]);
|
||||
expect(malformed[0]).toMatchObject({ index: 0 });
|
||||
expect(malformed[0]).not.toHaveProperty("label");
|
||||
});
|
||||
|
||||
it("indexes against the aggregated list, not the page", () => {
|
||||
// Page 2 of a paginated walk: an index that restarted per page would point
|
||||
// at the wrong entry in the list the user is looking at.
|
||||
const { malformed } = salvageListItems({
|
||||
method: "tools/list",
|
||||
items: [{ name: "ok" }, { name: 5 }],
|
||||
schema: ItemSchema,
|
||||
startIndex: 10,
|
||||
});
|
||||
expect(malformed[0]?.index).toBe(11);
|
||||
});
|
||||
|
||||
it("salvages nothing from a wholly conforming page", () => {
|
||||
const { valid, malformed } = salvageListItems({
|
||||
method: "prompts/list",
|
||||
items: [{ name: "a" }, { name: "b" }],
|
||||
schema: ItemSchema,
|
||||
});
|
||||
expect(valid).toHaveLength(2);
|
||||
expect(malformed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeMalformed", () => {
|
||||
it("names the single dropped entry and why", () => {
|
||||
expect(
|
||||
summarizeMalformed([
|
||||
{
|
||||
method: "resources/templates/list",
|
||||
index: 1,
|
||||
label: "array_annotations",
|
||||
reason: "annotations: expected object, received array",
|
||||
},
|
||||
]),
|
||||
).toBe(
|
||||
"Dropped 1 malformed entry — array_annotations: annotations: expected object, received array",
|
||||
);
|
||||
});
|
||||
|
||||
it("counts the rest when several failed, falling back to the index", () => {
|
||||
const summary = summarizeMalformed([
|
||||
{ method: "tools/list", index: 0, reason: "expected object" },
|
||||
{ method: "tools/list", index: 3, reason: "expected object" },
|
||||
]);
|
||||
expect(summary).toContain("Dropped 2 malformed entries");
|
||||
expect(summary).toContain("index 0");
|
||||
expect(summary).toContain("(+1 more)");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue