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:
parent
ebeaa142df
commit
fe5b622fd1
1 changed files with 1402 additions and 64 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue