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
10 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>
);
}

View file

@ -3,6 +3,8 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type { Prompt } from "@modelcontextprotocol/client";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning";
import type { MalformedListItem } from "@inspector/core/mcp";
import {
ListPaginationControls,
type ListPaginationControlsProps,
@ -41,6 +43,12 @@ export interface PromptControlsProps {
* A failed list load, surfaced above the list instead of leaving the panel
* empty (which reads as "this server has none") (#1953).
*/
/**
* Entries the client dropped from this list because they failed the MCP
* schema. Rendered as a warning above the list, which still shows the rest
* (#1909).
*/
malformedListItems?: MalformedListItem[];
loadError?: Error | null;
/** Pagination controls (#1721). */
pagination: ListPaginationControlsProps;
@ -54,6 +62,7 @@ export function PromptControls({
searchText = "",
listChanged,
onRefreshList,
malformedListItems = [],
loadError,
pagination,
onSearchChange,
@ -83,6 +92,11 @@ export function PromptControls({
/>
<ListPaginationControls {...pagination} />
<ListLoadError error={loadError} what="prompts" onRetry={onRefreshList} />
<MalformedItemsWarning
items={malformedListItems}
method="prompts/list"
what="prompts"
/>
<ListScroll viewportRef={viewportRef}>
<Stack gap="xs">
{filteredPrompts.map((prompt) => (

View file

@ -14,6 +14,8 @@ import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning";
import type { MalformedListItem } from "@inspector/core/mcp";
import {
ListPaginationControls,
type ListPaginationControlsProps,
@ -73,6 +75,12 @@ export interface ResourceControlsProps {
* A failed list load, surfaced above the list instead of leaving the panel
* empty (which reads as "this server has none") (#1953).
*/
/**
* Entries the client dropped from `resources/list` or
* `resources/templates/list` because they failed the MCP schema. Rendered as
* a warning above the list, which still shows the rest (#1909).
*/
malformedListItems?: MalformedListItem[];
loadError?: Error | null;
/** Pagination controls for the Resources list (#1721). */
pagination: ListPaginationControlsProps;
@ -117,6 +125,7 @@ export function ResourceControls({
openSections: controlledOpenSections,
listChanged,
onRefreshList,
malformedListItems = [],
loadError,
pagination,
onSearchChange,
@ -248,6 +257,16 @@ export function ResourceControls({
what="resources"
onRetry={onRefreshList}
/>
<MalformedItemsWarning
items={malformedListItems}
method="resources/list"
what="resources"
/>
<MalformedItemsWarning
items={malformedListItems}
method="resources/templates/list"
what="resource templates"
/>
{/* Stays inline: Accordion is a compound, `multiple`-discriminated generic,
so `.withProps({ multiple: true, ... })` loses its JSX call signature
(same tooling limit as Box). */}

View file

@ -15,6 +15,8 @@ import type { Tool } from "@modelcontextprotocol/client";
import type { ExcludedTool } from "@inspector/core/mcp/types.js";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning";
import type { MalformedListItem } from "@inspector/core/mcp";
import {
ListPaginationControls,
type ListPaginationControlsProps,
@ -37,6 +39,12 @@ export interface ToolControlsProps {
* A failed list load, surfaced above the list instead of leaving the panel
* empty (which reads as "this server has none") (#1953).
*/
/**
* Entries the client dropped from this list because they failed the MCP
* schema. Rendered as a warning above the list, which still shows the rest
* (#1909).
*/
malformedListItems?: MalformedListItem[];
loadError?: Error | null;
/** Pagination controls (#1721). */
pagination: ListPaginationControlsProps;
@ -127,6 +135,7 @@ export function ToolControls({
searchText = "",
listChanged,
onRefreshList,
malformedListItems = [],
loadError,
pagination,
onSearchChange,
@ -163,6 +172,11 @@ export function ToolControls({
/>
<ListPaginationControls {...pagination} />
<ListLoadError error={loadError} what="tools" onRetry={onRefreshList} />
<MalformedItemsWarning
items={malformedListItems}
method="tools/list"
what="tools"
/>
<SidebarScroll viewportRef={viewportRef}>
<Stack gap="xs">
{filteredTools.map(({ tool, key }) => (

View file

@ -8,6 +8,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import type { MalformedListItem } from "@inspector/core/mcp";
import type { GetPromptResult, Prompt } from "@modelcontextprotocol/client";
import { PromptControls } from "../../groups/PromptControls/PromptControls";
import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls";
@ -34,6 +35,11 @@ export interface PromptsScreenProps {
onUiChange: (next: PromptsUiState) => void;
onRefreshList: () => void;
/** A failed list load, rendered above the sidebar list (#1953). */
/**
* Entries dropped from this screen's list result(s) as malformed; the list
* panel warns about them above the list (#1909).
*/
malformedListItems?: MalformedListItem[];
loadError?: Error | null;
/** Pagination controls rendered in the sidebar (#1721). */
pagination: ListPaginationControlsProps;
@ -138,6 +144,7 @@ export function PromptsScreen({
completionsSupported,
onUiChange,
onRefreshList,
malformedListItems,
loadError,
pagination,
onGetPrompt,
@ -277,6 +284,7 @@ export function PromptsScreen({
listChanged={listChanged}
onRefreshList={onRefreshList}
loadError={loadError}
malformedListItems={malformedListItems}
pagination={pagination}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onSelectPrompt={handleSelectPrompt}

View file

@ -8,6 +8,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import type { MalformedListItem } from "@inspector/core/mcp";
import type {
ProtocolEra,
ReadResourceResult,
@ -58,6 +59,11 @@ export interface ResourcesScreenProps {
onUiChange: (next: ResourcesUiState) => void;
onRefreshList: () => void;
/** A failed list load, rendered above the sidebar list (#1953). */
/**
* Entries dropped from this screen's list result(s) as malformed; the list
* panel warns about them above the list (#1909).
*/
malformedListItems?: MalformedListItem[];
loadError?: Error | null;
/** Pagination controls rendered in the sidebar (#1721). */
pagination: ListPaginationControlsProps;
@ -172,6 +178,7 @@ export function ResourcesScreen({
subscriptionsSupported = true,
onUiChange,
onRefreshList,
malformedListItems,
loadError,
pagination,
onReadResource,
@ -329,6 +336,7 @@ export function ResourcesScreen({
listChanged={listChanged}
onRefreshList={onRefreshList}
loadError={loadError}
malformedListItems={malformedListItems}
pagination={pagination}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onOpenSectionsChange={(value) =>

View file

@ -1,4 +1,5 @@
import { Card, Flex, Stack, Text } from "@mantine/core";
import type { MalformedListItem } from "@inspector/core/mcp";
import type {
CallToolResult,
ReadResourceResult,
@ -50,6 +51,11 @@ export interface ToolsScreenProps {
/** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header`
* annotations (SEP-2243), shown in the sidebar with the reason (#1632). */
excludedTools?: ExcludedTool[];
/**
* Entries dropped from a list result as malformed, across every list method.
* Each list panel filters for its own and warns about them (#1909).
*/
malformedListItems?: MalformedListItem[];
callState?: ToolCallState;
ui: ToolsUiState;
listChanged: boolean;
@ -147,6 +153,7 @@ const EmptyState = Text.withProps({
export function ToolsScreen({
tools,
excludedTools,
malformedListItems,
callState,
ui,
listChanged,
@ -191,6 +198,7 @@ export function ToolsScreen({
<ToolControls
tools={tools}
excludedTools={excludedTools}
malformedListItems={malformedListItems}
selectedName={selectedToolName}
searchText={search}
listChanged={listChanged}

View file

@ -6,6 +6,7 @@ import {
type ReactNode,
type Ref,
} from "react";
import type { MalformedListItem } from "@inspector/core/mcp";
import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink";
import {
AppShell,
@ -418,6 +419,11 @@ export interface InspectorViewProps {
/** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
* (SEP-2243); shown in the Tools sidebar with the reason (#1632). */
excludedTools?: ExcludedTool[];
/**
* Entries dropped from a list result as malformed, across every list method.
* Each list panel filters for its own and warns about them (#1909).
*/
malformedListItems?: MalformedListItem[];
prompts: Prompt[];
resources: Resource[];
resourceTemplates: ResourceTemplate[];
@ -632,6 +638,7 @@ export function InspectorView({
latencyMs,
tools,
excludedTools = [],
malformedListItems = [],
prompts,
resources,
resourceTemplates,
@ -1365,6 +1372,7 @@ export function InspectorView({
<ToolsScreen
tools={tools}
excludedTools={excludedTools}
malformedListItems={malformedListItems}
callState={toolCallState}
ui={toolsUi}
listChanged={toolsListChanged}
@ -1405,6 +1413,7 @@ export function InspectorView({
<ScreenStage active={activeTab === "Prompts"}>
<PromptsScreen
prompts={prompts}
malformedListItems={malformedListItems}
getPromptState={getPromptState}
ui={promptsUi}
listChanged={promptsListChanged}
@ -1421,6 +1430,7 @@ export function InspectorView({
<ScreenStage active={activeTab === "Resources"}>
<ResourcesScreen
resources={resources}
malformedListItems={malformedListItems}
templates={resourceTemplates}
subscriptions={subscriptions}
subscriptionStreamState={subscriptionStreamState}