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:
parent
1e427c05f3
commit
fad8480376
4 changed files with 117 additions and 15 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue