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

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([]);
});
});