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
3 changed files with 1402 additions and 64 deletions

View file

@ -0,0 +1,89 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MalformedItemsWarning } from "./MalformedItemsWarning";
const meta: Meta<typeof MalformedItemsWarning> = {
title: "Elements/MalformedItemsWarning",
component: MalformedItemsWarning,
};
export default meta;
type Story = StoryObj<typeof MalformedItemsWarning>;
/** The failure this was built for: a PHP server whose empty `annotations`
* object reaches the wire as `[]` (#1909). */
export const OneDroppedTemplate: Story = {
args: {
method: "resources/templates/list",
what: "resource templates",
items: [
{
method: "resources/templates/list",
index: 1,
label: "array_annotations",
reason: "annotations: Invalid input: expected object, received array",
},
],
},
};
export const SeveralDropped: Story = {
args: {
method: "tools/list",
what: "tools",
items: [
{
method: "tools/list",
index: 0,
label: "get_weather",
reason: "inputSchema: Invalid input: expected object, received string",
},
{
method: "tools/list",
index: 4,
label: "add",
reason: "name: Invalid input: expected string, received number",
},
],
},
};
/** An entry too broken to carry a name is reported by its position. */
export const UnlabeledEntry: Story = {
args: {
method: "prompts/list",
what: "prompts",
items: [
{
method: "prompts/list",
index: 2,
reason: "Invalid input: expected object, received null",
},
],
},
};
/** Entries belonging to another list method are ignored — the same set is
* passed to every panel, and each selects its own. */
export const OtherMethodOnly: Story = {
args: {
method: "resources/templates/list",
what: "resource templates",
items: [
{
method: "tools/list",
index: 0,
label: "get_weather",
reason: "inputSchema: Invalid input: expected object, received string",
},
],
},
};
/** The resting state: a conforming server, nothing rendered. */
export const Conforming: Story = {
args: {
method: "resources/templates/list",
what: "resource templates",
items: [],
},
};

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import type { MalformedListItem } from "@inspector/core/mcp";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { MalformedItemsWarning } from "./MalformedItemsWarning";
const TEMPLATE_ENTRY: MalformedListItem = {
method: "resources/templates/list",
index: 1,
label: "array_annotations",
reason: "annotations: Invalid input: expected object, received array",
};
const TOOL_ENTRY: MalformedListItem = {
method: "tools/list",
index: 0,
label: "get_weather",
reason: "inputSchema: Invalid input: expected object, received string",
};
describe("MalformedItemsWarning", () => {
it("names the dropped entry and why it was dropped", () => {
renderWithMantine(
<MalformedItemsWarning
items={[TEMPLATE_ENTRY]}
method="resources/templates/list"
what="resource templates"
/>,
);
expect(screen.getByText("1 malformed entry dropped")).toBeInTheDocument();
expect(screen.getByText("array_annotations")).toBeInTheDocument();
expect(screen.getByText(TEMPLATE_ENTRY.reason)).toBeInTheDocument();
});
it("renders nothing when the server was conforming", () => {
renderWithMantine(
<MalformedItemsWarning
items={[]}
method="resources/templates/list"
what="resource templates"
/>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("ignores entries belonging to another list method", () => {
// Every panel receives the whole set and selects its own — a tools failure
// must not surface above the templates list.
renderWithMantine(
<MalformedItemsWarning
items={[TOOL_ENTRY]}
method="resources/templates/list"
what="resource templates"
/>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("get_weather")).not.toBeInTheDocument();
});
it("pluralizes the count and lists every entry", () => {
renderWithMantine(
<MalformedItemsWarning
items={[
TOOL_ENTRY,
{ ...TOOL_ENTRY, index: 4, label: "add", reason: "name: bad" },
TEMPLATE_ENTRY,
]}
method="tools/list"
what="tools"
/>,
);
expect(screen.getByText("2 malformed entries dropped")).toBeInTheDocument();
expect(screen.getByText("get_weather")).toBeInTheDocument();
expect(screen.getByText("add")).toBeInTheDocument();
// The template entry belongs to the other panel.
expect(screen.queryByText("array_annotations")).not.toBeInTheDocument();
});
it("falls back to the position when the entry had no usable name", () => {
renderWithMantine(
<MalformedItemsWarning
items={[
{
method: "prompts/list",
index: 2,
reason: "Invalid input: expected object, received null",
},
]}
method="prompts/list"
what="prompts"
/>,
);
expect(screen.getByText("entry 2")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,99 @@
import { Alert, Code, List, ScrollArea, Text } from "@mantine/core";
import type { MalformedListItem } from "@inspector/core/mcp";
export interface MalformedItemsWarningProps {
/**
* Every malformed entry the client is currently reporting, across all list
* methods — pass the hook's set straight through; the component selects its
* own by {@link method}.
*/
items: MalformedListItem[];
/** The list method this panel renders, e.g. `"resources/templates/list"`. */
method: string;
/** What the entries are, for the alert title — e.g. "resource templates". */
what: string;
}
// Yellow, not red: unlike ListLoadError this is not a failure — the list DID
// load and is rendered below. It reports what the server sent that the
// Inspector could not accept.
const WarningAlert = Alert.withProps({
color: "yellow",
variant: "light",
});
// The entry list stays compact; the reasons are the payload, so they get the
// monospace treatment while the labels stay in body type.
const EntryList = List.withProps({
size: "xs",
spacing: 4,
withPadding: true,
});
const EntryReason = Code.withProps({
variant: "wrapping",
});
// A server can be wrong about many entries at once; cap the height so a badly
// broken list can't push the list itself off the sidebar.
const EntryScroll = ScrollArea.withProps({
mah: 180,
type: "auto",
});
const SummaryText = Text.withProps({
size: "xs",
mb: 4,
});
// The entry's identity, emphasized against its monospaced reason.
const EntryLabel = Text.withProps({
component: "span",
size: "xs",
fw: 600,
});
/** `label` when the entry had one, else its position in the response. */
function describeEntry(item: MalformedListItem): string {
return item.label ?? `entry ${item.index}`;
}
/**
* "Some entries were dropped" — the visible half of the #1909 salvage.
*
* The client keeps the valid entries of a list whose result failed schema
* validation, rather than losing the whole list to one bad entry. Dropping them
* silently would trade one lie for another, so what was dropped, and why, is
* reported here beside the list that rendered without them.
*/
export function MalformedItemsWarning({
items,
method,
what,
}: MalformedItemsWarningProps) {
const mine = items.filter((item) => item.method === method);
if (mine.length === 0) return null;
return (
<WarningAlert
title={`${mine.length} malformed ${
mine.length === 1 ? "entry" : "entries"
} dropped`}
>
<SummaryText>
The server returned {what} that don&apos;t match the MCP schema. The
rest of the list is shown below.
</SummaryText>
<EntryScroll>
<EntryList>
{mine.map((item) => (
<List.Item key={`${item.method}:${item.index}`}>
<EntryLabel>{describeEntry(item)}</EntryLabel>{" "}
<EntryReason>{item.reason}</EntryReason>
</List.Item>
))}
</EntryList>
</EntryScroll>
</WarningAlert>
);
}