fix: only attribute a decode rejection to a Protocol entry

markResponseRejected was called for ANY refresh failure. The id it
recovers is "the last response received for this method", which is the
failing exchange only when a response actually arrived and was refused
while decoding. A transport drop, a timeout, or an abort produces no
response frame at all — the last-answered id then still points at an
EARLIER successful call, so marking it stamped "Rejected by the
Inspector" onto an exchange that worked. That is the same class of lie
this issue exists to remove.

A server-sent JSON-RPC error is excluded for a different reason: the id
would be right, but the failure is the server's and its entry already
renders as an error from the error frame.

Gated on SdkErrorCode.InvalidResult / UnsupportedResultType — decisions
the client made about a frame in hand. The failure is still recorded as
list state either way; only the Protocol attribution is gated.

Caught in Copilot's review of #1953.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
This commit is contained in:
cliffhall 2026-08-08 17:26:36 -04:00
commit fad8480376

View file

@ -17,6 +17,7 @@ import type {
CacheMode,
ServerCapabilities,
} from "@modelcontextprotocol/client";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { isTerminalStatus } from "../types.js";
import { TypedEventTarget } from "../typedEventTarget.js";
@ -29,6 +30,38 @@ 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).
@ -310,11 +343,14 @@ export abstract class ManagedListState<
// Attribute the failure to the response it came from, so the Protocol
// entry stops rendering a rejected result as a clean success (#1953).
// Must happen in this catch, while the correlation window the client
// documents is still valid.
this.client?.markResponseRejected?.(
this.config.listMethod,
error.message,
);
// documents is still valid — and ONLY for a decode rejection, see
// `isClientDecodeRejection`.
if (isClientDecodeRejection(err)) {
this.client?.markResponseRejected?.(
this.config.listMethod,
error.message,
);
}
throw err;
}
this.setError(null);