Two findings, both about the fallback accepting more than it should. - The lenient page schema validated pagination and nothing else, so a response carrying a malformed entry AND an unrelated top-level violation salvaged: the entry was reported and the other violation vanished with the strict error that caught it. The page schema is now DERIVED from the method's real result schema with only its item array relaxed (`lenientListPageSchema`), so every other field — `_meta`, `nextCursor`, anything a future revision adds — is still validated. The modern era envelope the raw-wire path bypasses (`resultType`, `ttlMs`, `cacheScope`) is checked explicitly for the same reason. Salvage is also narrowed to `InvalidResult`: `UnsupportedResultType` means the codec doesn't recognize what KIND of frame arrived, so it isn't a list with a bad entry and reporting an item-level reason would hide a protocol failure. Marking the Protocol entry still applies to both, hence two predicates. - On every rethrow path the re-walk had already answered the method, so the caller's own `markResponseRejected` (in `ManagedListState.refresh`) landed on the lenient page that succeeded and left the refused response clean. The correlation is restored before rethrowing — deleted when nothing had answered before, so the outer mark is a no-op rather than a lie. `nextCursorOf` / `rawItemsOf` narrow the page's fields instead of casting: the runtime-keyed `extend()` loses the shape, and asserting it back would be exactly the unjustified cast the last round removed. 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>
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
/**
|
|
* 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)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<string, unknown>).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"),
|
|
ttlMs: z.number(),
|
|
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<string, unknown>)[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<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}`;
|
|
}
|