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

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;

199
core/mcp/listSalvage.ts Normal file
View file

@ -0,0 +1,199 @@
/**
* Per-item salvage for paginated list results (#1909).
*
* The SDK validates a list result as a whole: one non-conforming element
* rejects the entire response, so a server with a single malformed entry
* appears to have NO tools/resources/templates/prompts at all. That is exactly
* backwards for a debugging tool — the one thing the user needs to see is which
* entry is wrong and why, and the other entries are still perfectly usable.
*
* A real report: a PHP server whose empty `annotations` object reaches the wire
* as `[]` (`json_encode` of an empty array, or of `(array) new stdClass()`)
* instead of `{}` made every resource template vanish behind "Couldn't load
* resources" (#1909).
*
* The strict path is left alone. `InspectorClient.listAll*` still calls the
* SDK's cache-aware aggregate first, and only falls back to a lenient re-walk
* when that rejects a result it received — so a conforming server pays nothing,
* and the fallback is a pure recovery path.
*
* Salvaging does NOT mean accepting: a salvaged entry is dropped from the list
* and reported as {@link MalformedListItem}, and the Protocol entry is still
* marked rejected. The Inspector keeps telling the truth about the response; it
* just stops throwing away the valid part of it.
*/
import { z } from "zod/v4";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
/**
* One entry the Inspector dropped from a list result because it failed the
* spec's schema for that primitive.
*/
export interface MalformedListItem {
/** The list method that carried it, e.g. `"resources/templates/list"`. */
method: string;
/**
* Zero-based position in the aggregated list. An entry too broken to carry a
* name is still identifiable by where it sat in the response.
*/
index: number;
/** Best-effort label read off the raw entry, when it has a usable one. */
label?: string;
/** Why it was rejected — the first validation issue, with its field path. */
reason: string;
}
/**
* Whether a failed fetch is the CLIENT refusing a response it received, rather
* than the request never producing one.
*
* This gates both `markResponseRejected` (#1953) and the salvage fallback
* (#1909), and the distinction is load-bearing for each. For the Protocol-entry
* marking: the correlation recovers the request id as "the last response
* received for this method", which is only the failing exchange when a response
* actually just arrived and was refused while decoding. A transport drop, a
* timeout, or an aborted request produces no response frame at all — the
* last-answered id then still points at some EARLIER, successful call, and
* marking it would stamp "Rejected by the Inspector" onto an exchange that
* succeeded. For the salvage fallback: re-walking the list only makes sense
* when there IS a result to salvage; re-issuing the request after a timeout
* would just double the wait before failing anyway.
*
* A server-sent JSON-RPC error is excluded for a different reason: it is a real
* response, so the id would be right, but the failure is the server's. Its
* entry already renders as an error from the error frame itself, and blaming
* the Inspector for it would misattribute the cause.
*
* `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed
* validation for the negotiated era"; `UnsupportedResultType` is its sibling
* for a `resultType` the codec has no handling for. Both are decisions the
* client made about a frame in hand.
*/
export function isClientDecodeRejection(err: unknown): boolean {
return (
SdkError.isInstance(err) &&
(err.code === SdkErrorCode.InvalidResult ||
err.code === SdkErrorCode.UnsupportedResultType)
);
}
/**
* A list page parsed with everything except pagination left unvalidated.
*
* `looseObject` keeps the unknown keys, so the primitive array (`tools`,
* `resources`, `resourceTemplates`, `prompts`) survives as `unknown` for
* per-item validation. Only `nextCursor` is typed, because the walk itself
* depends on it — if THAT is malformed the page is unusable and the strict
* error should stand.
*/
export const LenientListPageSchema = z.looseObject({
nextCursor: z.string().optional(),
});
export type LenientListPage = z.infer<typeof LenientListPageSchema>;
/** Read the primitive array out of a leniently-parsed page. */
export function rawItemsOf(page: LenientListPage, itemsKey: string): unknown[] {
const value = (page as Record<string, unknown>)[itemsKey];
return Array.isArray(value) ? value : [];
}
/**
* Best-effort human label for a raw entry: whichever identifying string field
* it happens to carry. Undefined when the entry is too broken to have one (a
* bare string, a number, a null) — the caller falls back to the index.
*/
export function labelForRawItem(value: unknown): string | undefined {
if (typeof value !== "object" || value === null) return undefined;
const record = value as Record<string, unknown>;
for (const key of ["name", "uriTemplate", "uri", "title"]) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.length > 0) return candidate;
}
return undefined;
}
/**
* One-line summary of why an entry failed: the first issue, prefixed with its
* field path so `annotations` is named rather than left for the reader to find
* in a wall of JSON. The path is relative to the entry, not the response, since
* the entry is what we're reporting on.
*/
export function describeIssues(error: z.ZodError): string {
const [issue] = error.issues;
/* v8 ignore next -- zod never produces a failed parse with zero issues; guards against an empty message if it ever did */
if (!issue) return "did not match the expected shape";
const path = issue.path.join(".");
const detail = expectedDetail(issue);
const message = detail ? `${issue.message} (${detail})` : issue.message;
return path.length > 0 ? `${path}: ${message}` : message;
}
/**
* `expected object` — appended because the issue's own message cannot be relied
* on to carry it. zod's browser build reports a bare "Invalid input" where Node
* says "Invalid input: expected object, received array", and the expected type
* is the whole diagnostic: without it the warning says something is wrong but
* not what would be right. Skipped when the message already spells it out, so
* the Node text doesn't end up saying it twice.
*/
function expectedDetail(issue: z.core.$ZodIssue): string | undefined {
if (!("expected" in issue) || typeof issue.expected !== "string") {
return undefined;
}
const detail = `expected ${issue.expected}`;
return issue.message.includes(detail) ? undefined : detail;
}
/**
* Validate a page's entries one at a time, keeping the ones that pass.
*
* `startIndex` is the running count across pages, so an index in the report
* refers to the aggregated list the user sees rather than resetting per page.
*/
export function salvageListItems<T>({
method,
items,
schema,
startIndex = 0,
}: {
method: string;
items: unknown[];
schema: z.ZodType<T>;
startIndex?: number;
}): { valid: T[]; malformed: MalformedListItem[] } {
const valid: T[] = [];
const malformed: MalformedListItem[] = [];
items.forEach((item, offset) => {
const parsed = schema.safeParse(item);
if (parsed.success) {
valid.push(parsed.data);
return;
}
const label = labelForRawItem(item);
malformed.push({
method,
index: startIndex + offset,
...(label !== undefined && { label }),
reason: describeIssues(parsed.error),
});
});
return { valid, malformed };
}
/**
* Summary line used for the Protocol entry's rejection reason, so the Network /
* Protocol view still says the response was non-conforming even though the list
* rendered (#1953 + #1909).
*/
export function summarizeMalformed(malformed: MalformedListItem[]): string {
const [first] = malformed;
/* v8 ignore next -- callers only summarize a non-empty set */
if (!first) return "";
const where = first.label ?? `index ${first.index}`;
const rest = malformed.length > 1 ? ` (+${malformed.length - 1} more)` : "";
return `Dropped ${malformed.length} malformed ${
malformed.length === 1 ? "entry" : "entries"
} — ${where}: ${first.reason}${rest}`;
}

View file

@ -17,7 +17,7 @@ import type {
CacheMode,
ServerCapabilities,
} from "@modelcontextprotocol/client";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { isClientDecodeRejection } from "../listSalvage.js";
import { isTerminalStatus } from "../types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
@ -30,38 +30,6 @@ import { TypedEventTarget } from "../typedEventTarget.js";
*/
export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250;
/**
* Whether a failed fetch is the CLIENT refusing a response it received, rather
* than the request never producing one.
*
* This gates `markResponseRejected`, and the distinction is load-bearing. That
* correlation recovers the request id as "the last response received for this
* method", which is only the failing exchange when a response actually just
* arrived and was refused while decoding. A transport drop, a timeout, or an
* aborted request produces no response frame at all — the last-answered id then
* still points at some EARLIER, successful call, and marking it would stamp
* "Rejected by the Inspector" onto an exchange that succeeded. That is the very
* class of lie this issue exists to remove, so it must not be traded for
* another one.
*
* A server-sent JSON-RPC error is excluded for a different reason: it is a real
* response, so the id would be right, but the failure is the server's. Its
* entry already renders as an error from the error frame itself, and blaming
* the Inspector for it would misattribute the cause.
*
* `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed
* validation for the negotiated era"; `UnsupportedResultType` is its sibling
* for a `resultType` the codec has no handling for. Both are decisions the
* client made about a frame in hand.
*/
function isClientDecodeRejection(err: unknown): boolean {
return (
SdkError.isInstance(err) &&
(err.code === SdkErrorCode.InvalidResult ||
err.code === SdkErrorCode.UnsupportedResultType)
);
}
/**
* Every managed-list event map carries the list-changed indicator event and the
* last-fetch error (#1953).