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
7 changed files with 1402 additions and 64 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
199
core/mcp/listSalvage.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue