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:
cliffhall 2026-08-15 16:52:11 -04:00
commit fe5b622fd1
2 changed files with 1402 additions and 64 deletions

View 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)");
});
});

View file

@ -0,0 +1,320 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
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";
/**
* Per-item list salvage against a real connection (#1909).
*
* The reported server was PHP, whose empty `annotations` object reaches the
* wire as `[]` rather than `{}` — one non-conforming entry, and the SDK
* rejected the whole `resources/templates/list` result, so every template
* vanished behind "Couldn't load resources".
*
* A hand-rolled JSON-RPC server is used rather than a composable test server
* because the SDK's own server cannot emit these shapes: they are exactly what
* its types forbid. The transport, the SDK client, and the salvage path are all
* real.
*/
interface ListPage {
items: unknown[];
nextCursor?: string;
}
function jsonRpcResult(id: unknown, result: unknown) {
return { jsonrpc: "2.0", id, result };
}
/**
* Minimal streamable-HTTP MCP server that answers `initialize` and the list
* methods it was configured with, returning entries verbatim (malformed ones
* included).
*/
function startMalformedServer(initialPages: {
resourceTemplates?: ListPage[];
tools?: ListPage[];
}): Promise<{
url: string;
stop: () => Promise<void>;
calls: string[];
/** Swap what the server serves next, to model a server that got fixed. */
setPages: (next: {
resourceTemplates?: ListPage[];
tools?: ListPage[];
}) => void;
}> {
const calls: string[] = [];
let pages = initialPages;
const pageFor = (key: "resourceTemplates" | "tools", cursor?: string) => {
const configured = pages[key] ?? [];
const index = cursor === undefined ? 0 : Number(cursor);
return configured[index] ?? { items: [] };
};
const handler = async (req: IncomingMessage, res: ServerResponse) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks).toString();
// The SDK also issues bodyless requests (the SSE GET, the session DELETE on
// teardown). Answer them without pretending they're JSON-RPC.
if (raw.length === 0) {
res.writeHead(405).end();
return;
}
const body = JSON.parse(raw) as {
id?: unknown;
method: string;
params?: { cursor?: string };
};
calls.push(body.method);
// Notifications carry no id and get an empty 202.
if (body.id === undefined) {
res.writeHead(202).end();
return;
}
const send = (result: unknown) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(jsonRpcResult(body.id, result)));
};
if (body.method === "initialize") {
send({
protocolVersion: "2025-06-18",
capabilities: { resources: {}, tools: {} },
serverInfo: { name: "malformed-list-server", version: "1.0.0" },
});
return;
}
if (body.method === "resources/templates/list") {
const page = pageFor("resourceTemplates", body.params?.cursor);
send({
resourceTemplates: page.items,
...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),
});
return;
}
if (body.method === "tools/list") {
const page = pageFor("tools", body.params?.cursor);
send({
tools: page.items,
...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }),
});
return;
}
send({});
};
const server: Server = createServer((req, res) => {
void handler(req, res);
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
/* v8 ignore next -- listen() on a fresh server always yields an AddressInfo */
const port = typeof address === "object" && address ? address.port : 0;
resolve({
url: `http://127.0.0.1:${port}/mcp`,
calls,
setPages: (next) => {
pages = next;
},
stop: () =>
new Promise((done) => {
server.close(() => done());
}),
});
});
});
}
const VALID_TEMPLATE = {
name: "full_annotations",
uriTemplate: "annotated://full/{id}",
annotations: { audience: ["user"], priority: 0.8 },
};
// The #1909 shape: `[]` where the spec says object.
const PHP_EMPTY_ANNOTATIONS = {
name: "array_annotations",
uriTemplate: "annotated://array/{id}",
annotations: [],
};
const EMPTY_OBJECT_ANNOTATIONS = {
name: "empty_annotations",
uriTemplate: "annotated://empty/{id}",
annotations: {},
};
describe("InspectorClient list salvage (#1909)", () => {
let client: InspectorClient | null = null;
let stopServer: (() => Promise<void>) | null = null;
async function connectTo(url: string) {
client = new InspectorClient(
{ type: "streamable-http", url },
{ environment: { transport: createTransportNode } },
);
await client.connect();
return client;
}
beforeEach(() => {
client = null;
stopServer = null;
});
afterEach(async () => {
try {
await client?.disconnect();
} catch {
// Teardown only — the assertions already ran.
}
client = null;
await stopServer?.();
stopServer = null;
});
it("keeps the valid templates and reports the malformed one", async () => {
const server = await startMalformedServer({
resourceTemplates: [
{
items: [
EMPTY_OBJECT_ANNOTATIONS,
PHP_EMPTY_ANNOTATIONS,
VALID_TEMPLATE,
],
},
],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
const { resourceTemplates } = await connected.listAllResourceTemplates();
// Before the fix this threw, and the panel showed zero templates.
expect(resourceTemplates.map((t) => t.name)).toEqual([
"empty_annotations",
"full_annotations",
]);
expect(connected.getMalformedListItems()).toEqual([
{
method: "resources/templates/list",
index: 1,
label: "array_annotations",
reason: expect.stringMatching(/^annotations: /),
},
]);
});
it("accepts an empty annotations object without salvaging at all", async () => {
// `{}` is legal — every Annotations field is optional — so this must take
// the strict path and report nothing dropped.
const server = await startMalformedServer({
resourceTemplates: [{ items: [EMPTY_OBJECT_ANNOTATIONS] }],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
const { resourceTemplates } = await connected.listAllResourceTemplates();
expect(resourceTemplates).toHaveLength(1);
expect(connected.getMalformedListItems()).toEqual([]);
});
it("emits malformedListItemsChange so the UI can warn", async () => {
const server = await startMalformedServer({
resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
const seen: unknown[] = [];
connected.addEventListener("malformedListItemsChange", (event) => {
seen.push(event.detail);
});
await connected.listAllResourceTemplates();
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject([{ label: "array_annotations" }]);
});
it("marks the Protocol entry rejected even though the list rendered", async () => {
const server = await startMalformedServer({
resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
const log = new MessageLogState(connected);
await connected.listAllResourceTemplates();
const rejected = log
.getMessages()
.filter((entry) => entry.clientError !== undefined);
expect(rejected.length).toBeGreaterThan(0);
expect(rejected.at(-1)?.clientError).toContain("Dropped 1 malformed entry");
log.destroy();
});
it("salvages across pages, indexing against the aggregate", async () => {
const server = await startMalformedServer({
tools: [
{
items: [{ name: "ok_one", inputSchema: { type: "object" } }],
nextCursor: "1",
},
{ items: [{ name: "broken", inputSchema: "not-a-schema" }] },
],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
const { tools } = await connected.listAllTools();
expect(tools.map((t) => t.name)).toEqual(["ok_one"]);
expect(connected.getMalformedListItems()).toMatchObject([
{ method: "tools/list", index: 1, label: "broken" },
]);
});
it("clears a previous report once the server answers cleanly", async () => {
const server = await startMalformedServer({
resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
await connected.listAllResourceTemplates();
expect(connected.getMalformedListItems()).toHaveLength(1);
// The server is fixed; the next refresh must clear the stale report rather
// than leaving a warning about entries that are no longer wrong.
server.setPages({ resourceTemplates: [{ items: [VALID_TEMPLATE] }] });
await connected.listAllResourceTemplates({ cacheMode: "bypass" });
expect(connected.getMalformedListItems()).toEqual([]);
});
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
// survive rather than being swallowed into a partial list.
const server = await startMalformedServer({
resourceTemplates: [
{ items: [VALID_TEMPLATE], nextCursor: 42 as unknown as string },
],
});
stopServer = server.stop;
const connected = await connectTo(server.url);
await expect(connected.listAllResourceTemplates()).rejects.toThrow();
expect(connected.getMalformedListItems()).toEqual([]);
});
});