inspector/core/mcp/listSalvage.ts
cliffhall 71cbe27d83 fix: address Copilot round 5 on the list-salvage path (#1909)
Both findings are the same shape: the fallback accepting what the strict path
would not.

- `ModernResultEnvelopeSchema` typed `ttlMs` as any number, where the codec
  requires a non-negative integer. `ttlMs: -1` (or `0.5`) plus one bad entry
  would salvage, reporting the entry and hiding the envelope violation. Tightened
  to `z.int().min(0)`.

- The item schema was the SDK's era-NEUTRAL `ToolSchema`, and the negotiated
  era's wire schema is stricter in ways the SDK does not export. Verified
  against SDK 2.0.0 on a real connection: on legacy, a tool with
  `outputSchema: { type: "array" }` is refused by the strict aggregate but
  accepted by the neutral schema; on modern, `execution` is STRIPPED by the
  strict path but kept by the neutral one. Either way a salvaged list could show
  — and let the user call — a tool the connection's era says isn't valid.

  `toolItemSchemaForEra` closes both. Replicating rules that live in the SDK's
  internals is a real cost; `listSalvage-era.test.ts` is what makes it safe to
  pay, pinning each rule to the strict path's OBSERVED behavior so a divergence
  that closes or widens fails loudly instead of rotting.

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>
2026-08-15 18:46:54 -04:00

305 lines
13 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";
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<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"),
// 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<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}`;
}
/**
* 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<Tool> {
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(),
});
}