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

View file

@ -60,7 +60,11 @@ v2/main/
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks
│ │ # extension wire schemas + normalize/handle helpers,
│ │ # used by the raw-wire tasks/* channel — #1631)
│ │ # used by the raw-wire tasks/* channel — #1631;
│ │ # listSalvage.ts: per-item salvage for list results —
│ │ # keeps the valid entries when one is non-conforming
│ │ # instead of losing the whole list, and owns the
│ │ # shared isClientDecodeRejection predicate — #1909)
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
│ │ │ # server.json parser, strategy registry + well-known

View file

@ -598,6 +598,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
serverInfo: undefined,
instructions: undefined,
excludedTools: [],
malformedListItems: [],
appRendererClient: null,
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),

View file

@ -990,6 +990,7 @@ function App() {
protocolEra,
discoverResult,
excludedTools,
malformedListItems,
lastError,
} = useInspectorClient(inspectorClient);
const {
@ -4376,6 +4377,7 @@ function App() {
latencyMs={latencyMs}
tools={tools}
excludedTools={excludedTools}
malformedListItems={malformedListItems}
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}

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}

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

View file

@ -38,6 +38,7 @@ import type {
ExcludedTool,
} from "../types.js";
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
import type { MalformedListItem } from "../listSalvage.js";
import type { JsonValue } from "../../json/jsonUtils.js";
type ListResult<TKey extends string, TItem> = {
@ -282,6 +283,18 @@ export class FakeInspectorClient
this.dispatchTypedEvent("excludedToolsChange", excluded);
}
private malformedListItems: MalformedListItem[] = [];
getMalformedListItems(): MalformedListItem[] {
return this.malformedListItems;
}
/** Test helper: set the malformed-entry set and emit the change (#1909). */
setMalformedListItems(malformed: MalformedListItem[]): void {
this.malformedListItems = malformed;
this.dispatchTypedEvent("malformedListItemsChange", malformed);
}
getDiscoverResult(): DiscoverResult | undefined {
return this.discoverResult;
}

View file

@ -42,6 +42,9 @@ export type {
ToolCallInvocation,
} from "./types.js";
// Per-item list salvage (#1909)
export type { MalformedListItem } from "./listSalvage.js";
// Re-export JSON utilities
export type { JsonValue } from "../json/jsonUtils.js";
export {

View file

@ -159,10 +159,26 @@ import {
ListResourcesResultSchema,
ListResourceTemplatesResultSchema,
ListPromptsResultSchema,
// Per-primitive item schemas — the salvage fallback validates a list's
// entries one at a time with these when the whole-result parse rejects
// (#1909, see `listAllSalvaging`).
ToolSchema,
ResourceSchema,
ResourceTemplateSchema,
PromptSchema,
} from "@modelcontextprotocol/core";
import type { ClientResult } from "@modelcontextprotocol/client";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
import { z } from "zod/v4";
import { validateToolOutput } from "./toolOutputValidation.js";
import {
LenientListPageSchema,
isClientDecodeRejection,
rawItemsOf,
salvageListItems,
summarizeMalformed,
type MalformedListItem,
} from "./listSalvage.js";
import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js";
import {
type JsonValue,
@ -407,6 +423,11 @@ export class InspectorClient extends InspectorClientEventTarget {
// annotations (SEP-2243), recomputed on every aggregate tools refresh and
// surfaced so the Tools tab can show why a tool vanished (#1632).
private excludedTools: ExcludedTool[] = [];
// Entries the Inspector dropped from a list result because they failed the
// spec schema for that primitive, keyed by list method. Populated only by the
// salvage fallback in `listAllSalvaging` — empty against a conforming server
// (#1909).
private malformedListItems = new Map<string, MalformedListItem[]>();
// The capabilities this Inspector client advertises to the server during the
// initialize handshake. Built once in setupClient() and snapshotted here so
// UI surfaces (Server Info modal) can display them without poking at the
@ -2211,6 +2232,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.protocolEra = undefined;
this.discoverResult = undefined;
this.excludedTools = [];
this.malformedListItems.clear();
// Read as "not opted in" while disconnected. This is no longer what stops
// it leaking into the next connection — `resetSessionState()` re-derives it
// at connect, so removing this would leak nothing (#1629). Note the web
@ -2230,6 +2252,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.dispatchTypedEvent("protocolEraChange", this.protocolEra);
this.dispatchTypedEvent("discoverResultChange", this.discoverResult);
this.dispatchTypedEvent("excludedToolsChange", this.excludedTools);
this.dispatchTypedEvent("malformedListItemsChange", []);
}
/**
@ -3126,12 +3149,20 @@ export class InspectorClient extends InspectorClientEventTarget {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(() =>
this.client!.listTools(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
);
const tools = await this.listAllSalvaging({
method: "tools/list",
itemsKey: "tools",
itemSchema: ToolSchema,
metadata: options?.metadata,
aggregate: async () => [
...(
await this.client!.listTools(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
)
).tools,
],
});
// Recompute the SEP-2243 excluded-tools set alongside the aggregate. The
// SDK already filtered `response.tools`, so it can't tell us what it
// dropped — {@link refreshExcludedTools} re-lists the RAW `tools/list` to
@ -3153,7 +3184,7 @@ export class InspectorClient extends InspectorClientEventTarget {
"Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete",
);
});
return { tools: [...response.tools] };
return { tools };
}
/**
@ -3187,6 +3218,146 @@ export class InspectorClient extends InspectorClientEventTarget {
this.dispatchTypedEvent("responseRejected", { id, reason });
}
/** Every entry dropped from a list result as malformed, across methods (#1909). */
getMalformedListItems(): MalformedListItem[] {
return [...this.malformedListItems.values()].flat();
}
/**
* Record (or clear) the malformed entries for one list method and notify.
*
* A no-op when the method had none and still has none, so the common case —
* every conforming refresh, of which there are many — costs no event and no
* re-render.
*/
private setMalformedListItems(
method: string,
malformed: MalformedListItem[],
): void {
const previous = this.malformedListItems.get(method) ?? [];
if (previous.length === 0 && malformed.length === 0) return;
if (malformed.length === 0) this.malformedListItems.delete(method);
else this.malformedListItems.set(method, malformed);
this.dispatchTypedEvent(
"malformedListItemsChange",
this.getMalformedListItems(),
);
}
/**
* Run a list aggregate, falling back to a per-item salvage when the SDK
* rejects the result it received (#1909).
*
* The strict aggregate runs first and unchanged, so a conforming server pays
* nothing: no extra request, no extra parse, and the SDK's response cache
* still serves it. Only an `InvalidResult` rejection — a response in hand
* that failed validation — reaches {@link salvageList}; every other failure
* (transport drop, timeout, server-sent error) rethrows untouched, because
* re-listing would not produce anything to salvage.
*/
private async listAllSalvaging<T>({
method,
itemsKey,
itemSchema,
aggregate,
metadata,
}: {
method: string;
itemsKey: string;
itemSchema: z.ZodType<T>;
aggregate: () => Promise<T[]>;
metadata?: Record<string, string>;
}): Promise<T[]> {
try {
const items = await this.invokeMcpClient(aggregate, { method });
this.setMalformedListItems(method, []);
return items;
} catch (err) {
if (!isClientDecodeRejection(err)) throw err;
return this.salvageList({ method, itemsKey, itemSchema, metadata, err });
}
}
/**
* Re-walk a list method with pagination validated but entries left raw, then
* validate each entry on its own — keeping the good ones and reporting the
* rest (#1909).
*
* Two deliberate properties:
*
* - **The original error wins when nothing is salvageable.** If every entry
* validates here, the strict rejection was about something else (a
* top-level field, an era codec decision), so there is no per-item story to
* tell and swallowing it would hide a real failure. Rethrow the original.
* - **A salvaged response is still a rejected response.** The Protocol entry
* is marked (#1953) with what was dropped, so the wire truth survives even
* though the list now renders.
*
* This walk is deliberately un-cached (it uses the raw per-page path), which
* is fine: it only runs against a server that just returned a non-conforming
* result.
*/
private async salvageList<T>({
method,
itemsKey,
itemSchema,
metadata,
err,
}: {
method: string;
itemsKey: string;
itemSchema: z.ZodType<T>;
metadata?: Record<string, string>;
err: unknown;
}): Promise<T[]> {
const valid: T[] = [];
const malformed: MalformedListItem[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
do {
const effectiveMeta = this.mergeMeta(metadata);
const params = {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const page = await this.invokeMcpClient(
() =>
this.client!.request(
{ method, params },
LenientListPageSchema,
this.getRequestOptions(metadata?.progressToken),
),
{ method },
);
const salvaged = salvageListItems({
method,
items: rawItemsOf(page, itemsKey),
schema: itemSchema,
startIndex: valid.length + malformed.length,
});
valid.push(...salvaged.valid);
malformed.push(...salvaged.malformed);
cursor = page.nextCursor;
if (cursor !== undefined) {
/* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */
if (seenCursors.has(cursor)) break;
seenCursors.add(cursor);
}
} while (cursor !== undefined);
// Nothing per-item was wrong, so the strict rejection was about something
// this fallback cannot explain — surface it rather than hide it.
if (malformed.length === 0) throw err;
this.markResponseRejected(method, summarizeMalformed(malformed));
this.setMalformedListItems(method, malformed);
this.logger.warn(
{ method, malformed },
"Dropped malformed entries from a list result; the rest of the list was kept",
);
return valid;
}
/** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */
getExcludedTools(): ExcludedTool[] {
return this.excludedTools;
@ -4347,13 +4518,21 @@ export class InspectorClient extends InspectorClientEventTarget {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(() =>
this.client!.listResources(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
);
return { resources: [...response.resources] };
const resources = await this.listAllSalvaging({
method: "resources/list",
itemsKey: "resources",
itemSchema: ResourceSchema,
metadata: options?.metadata,
aggregate: async () => [
...(
await this.client!.listResources(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
)
).resources,
],
});
return { resources };
}
/**
@ -4504,15 +4683,21 @@ export class InspectorClient extends InspectorClientEventTarget {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(
() =>
this.client!.listResourceTemplates(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
{ method: "resources/templates/list" },
);
return { resourceTemplates: [...response.resourceTemplates] };
const resourceTemplates = await this.listAllSalvaging({
method: "resources/templates/list",
itemsKey: "resourceTemplates",
itemSchema: ResourceTemplateSchema,
metadata: options?.metadata,
aggregate: async () => [
...(
await this.client!.listResourceTemplates(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
)
).resourceTemplates,
],
});
return { resourceTemplates };
}
/**
@ -4559,13 +4744,21 @@ export class InspectorClient extends InspectorClientEventTarget {
if (!this.client) {
throw new Error("Client is not connected");
}
const response = await this.invokeMcpClient(() =>
this.client!.listPrompts(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
),
);
return { prompts: [...response.prompts] };
const prompts = await this.listAllSalvaging({
method: "prompts/list",
itemsKey: "prompts",
itemSchema: PromptSchema,
metadata: options?.metadata,
aggregate: async () => [
...(
await this.client!.listPrompts(
this.aggregateListParams(options?.metadata),
this.getCacheableRequestOptions(options?.cacheMode),
)
).prompts,
],
});
return { prompts };
}
/**

View file

@ -26,6 +26,7 @@ import type {
ResourceSubscriptionStreamState,
ExcludedTool,
} from "./types.js";
import type { MalformedListItem } from "./listSalvage.js";
import type {
Tool,
ServerCapabilities,
@ -62,6 +63,12 @@ export interface InspectorClientEventMap {
* connections (which don't exclude) and before connect (#1632).
*/
excludedToolsChange: ExcludedTool[];
/**
* Entries dropped from a list result because they failed the spec schema for
* their primitive, across every list method. Empty against a conforming
* server; a non-empty set means the list rendered without them (#1909).
*/
malformedListItemsChange: MalformedListItem[];
capabilitiesChange: ServerCapabilities | undefined;
serverInfoChange: Implementation | undefined;
instructionsChange: string | undefined;

View file

@ -37,6 +37,7 @@ import type {
Tool,
} from "@modelcontextprotocol/client";
import type { JsonValue } from "../json/jsonUtils.js";
import type { MalformedListItem } from "./listSalvage.js";
import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js";
import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
@ -65,6 +66,9 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
/** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
* (SEP-2243); empty on legacy/stdio connections (#1632). */
getExcludedTools(): ExcludedTool[];
/** Entries dropped from a list result as malformed, across every list method;
* empty against a conforming server (#1909). */
getMalformedListItems(): MalformedListItem[];
getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState;
getDiscoverResult(): DiscoverResult | undefined;
getServerSettings(): InspectorServerSettings | undefined;

Some files were not shown because too many files have changed in this diff Show more