/** * 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"; import { ToolSchema } from "@modelcontextprotocol/core"; import type { Tool } 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) ); } /** * Whether a decode rejection is one the per-item fallback may attempt at all. * * Narrower than {@link isClientDecodeRejection} on purpose, and the difference * is the point: `UnsupportedResultType` says the codec does not recognize what * KIND of frame arrived, so it is not a list result with a bad entry in it — * it is not a list result at all. Salvaging it would report an item-level * reason for a protocol-level failure and hide the real one. Marking the * Protocol entry rejected still applies to both, which is why the broader * predicate stays. */ export function isSalvageableRejection(err: unknown): boolean { return SdkError.isInstance(err) && err.code === SdkErrorCode.InvalidResult; } /** * The page schema for a salvage re-walk: the method's REAL result schema with * only its item array relaxed to `unknown[]`. * * Deriving it from the spec schema rather than hand-rolling a permissive object * is what keeps the fallback honest. A hand-rolled `{ nextCursor? }` validates * pagination and nothing else, so a response carrying a malformed item AND an * unrelated top-level violation would be accepted, the item reported, and the * other violation silently dropped along with the strict error that caught it. * Extending the real schema means every top-level field — `_meta`, `nextCursor`, * anything a future revision adds — is still validated; only the entries, which * this whole module exists to check one at a time, are left raw. */ export function lenientListPageSchema( resultSchema: z.ZodObject, itemsKey: string, ): z.ZodObject { return resultSchema.extend({ [itemsKey]: z.array(z.unknown()) }); } /** * Read the pagination cursor off a leniently-parsed page. * * The page is typed as a plain object because `extend()` on a runtime-selected * key loses the shape, so the fields are read through narrowing helpers rather * than asserted with a cast. The schema has already validated that a present * `nextCursor` is a string; this narrows the static type to match. */ export function nextCursorOf(page: unknown): string | undefined { if (typeof page !== "object" || page === null) return undefined; const value = (page as Record).nextCursor; return typeof value === "string" ? value : undefined; } /** * The era envelope a modern (2026-07-28) result must carry, checked separately * because it lives in the SDK's codec rather than in the spec result schema — * and the modern salvage path deliberately goes around that codec. * * Without this, a modern response that is missing its cache hints AND has one * bad entry would salvage: the entry gets reported and the envelope violation * disappears. A failure here means "not salvageable", so the strict error * stands — the safe direction if a future revision changes these rules, since * the strict path remains the authority on what is valid. */ export const ModernResultEnvelopeSchema = z.looseObject({ resultType: z.literal("complete"), // A non-negative INTEGER, matching the codec: `ttlMs: -1` or `0.5` is an // envelope violation the strict path rejects, and accepting it here on the // strength of an unrelated bad entry would hide it. ttlMs: z.int().min(0), cacheScope: z.enum(["public", "private"]), }); /** * Read the primitive array out of a leniently-parsed page. * * Returns `undefined` — NOT an empty array — when the member is missing or is * not an array, because those are different facts. An empty page is a normal * server answer; a page whose `tools` is the string `"invalid"` is a top-level * schema violation this fallback cannot explain, and reading it as "no entries" * would let the caller return a silently truncated list while the strict error * that was correct about it is discarded. */ export function rawItemsOf( page: unknown, itemsKey: string, ): unknown[] | undefined { if (typeof page !== "object" || page === null) return undefined; const value = (page as Record)[itemsKey]; return Array.isArray(value) ? value : undefined; } /** * 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; 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({ method, items, schema, startIndex = 0, }: { method: string; items: unknown[]; schema: z.ZodType; 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}`; } /** * The item contract for `tools/list` on a given era. * * The public `ToolSchema` is era-NEUTRAL, and the negotiated era's wire schema * is stricter in ways the SDK does not export. Validating candidates against * the neutral schema alone would let the fallback keep an entry the strict path * refused — an entry the user would then see, and could call, on a connection * whose era says it isn't valid. The two known divergences (verified against * SDK 2.0.0 by `listSalvage-era.test.ts`, which fails if either changes): * * - **legacy (2025)** requires `outputSchema.type === "object"`; the neutral * schema accepts any object, so a tool with `outputSchema: { type: "array" }` * would survive salvage while the strict parse rejects the whole page. * - **modern (2026)** deletes `execution`, and the strict path STRIPS it. Left * in place, a salvaged tool would carry a field the era doesn't have, and the * UI would render capability the connection can't honor. * * Replicating rules that live in the SDK's internals is a real cost, taken * deliberately: the alternative is a fallback that is quietly more permissive * than the path it stands in for. The tests pin each rule to observed SDK * behavior, so a divergence that closes (or widens) fails loudly rather than * rotting. */ export function toolItemSchemaForEra(isModern: boolean): z.ZodType { if (isModern) { // `execution` is not part of the 2026 wire shape; drop it as the codec does. return ToolSchema.transform((tool) => { if (tool.execution === undefined) return tool; const { execution: _execution, ...rest } = tool; return rest; }); } return ToolSchema.extend({ outputSchema: z.looseObject({ type: z.literal("object") }).optional(), }); }