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
4 changed files with 117 additions and 15 deletions

View file

@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import type { Prompt } from "@modelcontextprotocol/client";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState";
@ -289,7 +290,7 @@ describe("ManagedPromptsState", () => {
// pins THIS list's method string, which is what attributes a failure to the
// right Protocol entry (#1953).
it("records a failed load and attributes it to prompts/list", async () => {
const boom = new Error("nope");
const boom = new SdkError(SdkErrorCode.InvalidResult, "nope");
client.setStatus("connected");
client.listAllPrompts.mockRejectedValueOnce(boom);

View file

@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState";
@ -270,7 +271,7 @@ describe("ManagedResourceTemplatesState", () => {
// pins THIS list's method string, which is what attributes a failure to the
// right Protocol entry (#1953).
it("records a failed load and attributes it to resources/templates/list", async () => {
const boom = new Error("nope");
const boom = new SdkError(SdkErrorCode.InvalidResult, "nope");
client.setStatus("connected");
client.listAllResourceTemplates.mockRejectedValueOnce(boom);

View file

@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import type { Resource } from "@modelcontextprotocol/client";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
@ -296,7 +297,7 @@ describe("ManagedResourcesState", () => {
// pins THIS list's method string, which is what attributes a failure to the
// right Protocol entry (#1953).
it("records a failed load and attributes it to resources/list", async () => {
const boom = new Error("nope");
const boom = new SdkError(SdkErrorCode.InvalidResult, "nope");
client.setStatus("connected");
client.listAllResources.mockRejectedValueOnce(boom);

View file

@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import type { Tool } from "@modelcontextprotocol/client";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
@ -453,14 +454,76 @@ describe("ManagedToolsState", () => {
expect(state.getError()?.message).toBe("just a string");
});
it("attributes the failure to its Protocol entry", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
expect(client.markResponseRejected).toHaveBeenCalledWith(
"tools/list",
boom.message,
// Only a decode rejection may be attributed to a Protocol entry. The id is
// recovered as "the last response for this method", which is the failing
// exchange ONLY when a response actually arrived and was refused — see
// isClientDecodeRejection.
describe("Protocol-entry attribution", () => {
const decodeRejection = new SdkError(
SdkErrorCode.InvalidResult,
"Invalid result for tools/list: ttlMs required",
);
it("attributes a decode rejection to its Protocol entry", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(decodeRejection);
await expect(state.refresh()).rejects.toThrow(decodeRejection);
expect(client.markResponseRejected).toHaveBeenCalledWith(
"tools/list",
decodeRejection.message,
);
});
it("attributes an unsupported resultType too", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(
new SdkError(SdkErrorCode.UnsupportedResultType, "unknown type"),
);
await expect(state.refresh()).rejects.toThrow();
expect(client.markResponseRejected).toHaveBeenCalledWith(
"tools/list",
"unknown type",
);
});
// The regression this guard exists for: no response frame arrived, so the
// last-answered id still points at an EARLIER successful call. Marking it
// would stamp "Rejected by the Inspector" onto an exchange that worked.
it("does NOT attribute a transport failure", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(
new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed"),
);
await expect(state.refresh()).rejects.toThrow();
expect(client.markResponseRejected).not.toHaveBeenCalled();
});
it("does NOT attribute a request timeout", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(
new SdkError(SdkErrorCode.RequestTimeout, "Request timed out"),
);
await expect(state.refresh()).rejects.toThrow();
expect(client.markResponseRejected).not.toHaveBeenCalled();
});
// A real response, so the id would be right — but the failure is the
// server's, and its entry already renders as an error from the error
// frame. Blaming the Inspector would misattribute the cause.
it("does NOT attribute a plain (non-SDK) error", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
expect(client.markResponseRejected).not.toHaveBeenCalled();
});
it("still records every failure as state, attributed or not", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
expect(client.markResponseRejected).not.toHaveBeenCalled();
});
});
it("clears the error once a refresh succeeds", async () => {