fix: surface list-fetch failures instead of swallowing them

A list load that failed — a transport error, or a result the SDK's era
codec rejects as invalid — reported nothing. The connect-time refresh is
fired with no caller to await it (`void this.refresh()`), so its rejection
became an unhandled rejection: the header said "Connected", the panel
rendered an empty list indistinguishable from a server that has none, and
the failed exchange showed in the Protocol panel as a clean success.

Two surfaces now tell the truth:

- ManagedListState records the failure as observable state (`getError` +
  an `errorChange` event) and still re-throws, so App's auth-recovery
  wrapper keeps working. The four `useManaged*` hooks expose it via a
  shared `useManagedListError`, and the Tools/Prompts/Resources sidebars
  render a `ListLoadError` alert with the reason and a Retry.
- A response the client refused is attributed back to its Protocol entry
  (`markResponseRejected` → `responseRejected` → `MessageEntry.clientError`),
  which now renders an Error status and a "Rejected by the Inspector"
  alert rather than the green resultType badge alone. The SDK gives no
  request id with a decode failure, so the id is recovered by correlation;
  see the comment on `markResponseRejected` for why that is exact.

Also stops `listAllTools` from dropping a failing excluded-tools walk on
the floor — it stays non-fatal but is logged.

Closes #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 16:48:48 -04:00
commit f201032137
6 changed files with 1080 additions and 48 deletions

View file

@ -285,6 +285,22 @@ describe("ManagedPromptsState", () => {
});
});
// The base class owns the error plumbing (covered in managedToolsState); this
// 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");
client.setStatus("connected");
client.listAllPrompts.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
expect(client.markResponseRejected).toHaveBeenCalledWith(
"prompts/list",
"nope",
);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();

View file

@ -266,6 +266,22 @@ describe("ManagedResourceTemplatesState", () => {
expect(state.getResourceTemplates()).toEqual([]);
});
// The base class owns the error plumbing (covered in managedToolsState); this
// 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");
client.setStatus("connected");
client.listAllResourceTemplates.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
expect(client.markResponseRejected).toHaveBeenCalledWith(
"resources/templates/list",
"nope",
);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();

View file

@ -292,6 +292,22 @@ describe("ManagedResourcesState", () => {
});
});
// The base class owns the error plumbing (covered in managedToolsState); this
// 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");
client.setStatus("connected");
client.listAllResources.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
expect(client.markResponseRejected).toHaveBeenCalledWith(
"resources/list",
"nope",
);
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();

View file

@ -412,6 +412,110 @@ describe("ManagedToolsState", () => {
});
});
// A failed load used to vanish: the connect-time refresh is fired with no
// caller to await it, so its rejection became an unhandled rejection and the
// panel just rendered an empty list — indistinguishable from a server with no
// tools (#1953).
describe("load errors", () => {
const boom = new Error("Invalid result for tools/list: ttlMs required");
function waitForError(state: ManagedToolsState): Promise<Error | null> {
return waitForChangeEvent(state, "errorChange");
}
it("starts with no error", () => {
expect(state.getError()).toBeNull();
});
it("records a failed refresh as state AND re-throws", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
// The rejection still propagates: App's auth-recovery wrapper keys off it
// to detect a 401 and start a re-authorization.
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
});
it("dispatches errorChange with the error", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
const changed = waitForError(state);
await expect(state.refresh()).rejects.toThrow(boom);
expect(await changed).toBe(boom);
});
it("wraps a non-Error rejection", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce("just a string");
await expect(state.refresh()).rejects.toBe("just a string");
expect(state.getError()).toBeInstanceOf(Error);
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,
);
});
it("clears the error once a refresh succeeds", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const cleared = waitForError(state);
client.queueToolPages({ tools: [tool("a")] });
await state.refresh();
expect(await cleared).toBeNull();
expect(state.getError()).toBeNull();
});
it("keeps the connect-time failure in state instead of rejecting unobserved", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
const changed = waitForError(state);
await client.connect();
expect(await changed).toBe(boom);
});
it("keeps the auto-refresh failure in state instead of rejecting unobserved", async () => {
client.setServerSettings(AUTO_REFRESH_SETTINGS);
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
const changed = waitForError(state);
client.dispatchTypedEvent("toolsListChanged");
expect(await changed).toBe(boom);
});
it("clears the error on disconnect so it can't outlive its session", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const cleared = waitForError(state);
client.setStatus("disconnected");
expect(await cleared).toBeNull();
expect(state.getError()).toBeNull();
});
it("does not re-dispatch when the same error is recorded twice", async () => {
client.setStatus("connected");
client.listAllTools.mockRejectedValue(boom);
await expect(state.refresh()).rejects.toThrow(boom);
let fired = 0;
state.addEventListener("errorChange", () => {
fired++;
});
await expect(state.refresh()).rejects.toThrow(boom);
expect(fired).toBe(0);
});
});
it("destroy is idempotent", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();

View file

@ -234,4 +234,69 @@ describe("MessageLogState", () => {
state.destroy();
expect(() => state.destroy()).not.toThrow();
});
// A response the SERVER answered successfully but the CLIENT then refused
// (the SDK codec rejecting the result). The frame is a valid JSON-RPC
// result, so without the annotation the entry renders as a clean success
// (#1953).
describe("responseRejected", () => {
const REASON = "Invalid result for tools/list: ttlMs required";
it("annotates the request entry the response was folded into", () => {
client.dispatchTypedEvent("message", requestEntry(1));
client.dispatchTypedEvent("message", responseEntry(1));
const seen: MessageEntry[] = [];
state.addEventListener("message", (e) => seen.push(e.detail));
client.dispatchTypedEvent("responseRejected", { id: 1, reason: REASON });
expect(state.getMessages()[0]?.clientError).toBe(REASON);
expect(seen).toHaveLength(1);
});
it("annotates a standalone response entry with no matching request", () => {
client.dispatchTypedEvent("message", responseEntry(7));
client.dispatchTypedEvent("responseRejected", { id: 7, reason: REASON });
expect(state.getMessages()[0]?.clientError).toBe(REASON);
});
it("skips a request still awaiting its response", () => {
// Same JSON-RPC id reused after the first exchange completed: the
// pending one isn't what was rejected.
client.dispatchTypedEvent("message", requestEntry(1));
client.dispatchTypedEvent("message", responseEntry(1));
client.dispatchTypedEvent("message", requestEntry(1));
client.dispatchTypedEvent("responseRejected", { id: 1, reason: REASON });
const [completed, pending] = state.getMessages();
expect(pending?.clientError).toBeUndefined();
expect(completed?.clientError).toBe(REASON);
});
it("ignores an id that matches no entry", () => {
client.dispatchTypedEvent("message", requestEntry(1));
client.dispatchTypedEvent("message", responseEntry(1));
let fired = 0;
state.addEventListener("messagesChange", () => {
fired++;
});
client.dispatchTypedEvent("responseRejected", { id: 99, reason: REASON });
expect(fired).toBe(0);
expect(state.getMessages()[0]?.clientError).toBeUndefined();
});
it("never annotates a notification", () => {
client.dispatchTypedEvent("message", notificationEntry());
client.dispatchTypedEvent("responseRejected", {
id: 1,
reason: REASON,
});
expect(state.getMessages()[0]?.clientError).toBeUndefined();
});
});
});

View file

@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState";
import { useManagedListError } from "@inspector/core/react/useManagedListError";
// The shared subscription behind the four `useManaged*` hooks' `error` field
// (#1953). Exercised through ManagedToolsState — any managed list would do,
// since the error lives entirely in the shared base.
describe("useManagedListError", () => {
let client: FakeInspectorClient;
let state: ManagedToolsState;
const boom = new Error("Invalid result for tools/list: ttlMs required");
beforeEach(() => {
client = new FakeInspectorClient({
status: "connected",
capabilities: { tools: {} },
});
state = new ManagedToolsState(client, 0);
});
it("returns null when there is no state", () => {
const { result } = renderHook(() => useManagedListError(null));
expect(result.current).toBeNull();
});
it("returns null before any load fails", () => {
const { result } = renderHook(() => useManagedListError(state));
expect(result.current).toBeNull();
});
it("seeds from an error the state already holds", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const { result } = renderHook(() => useManagedListError(state));
expect(result.current).toBe(boom);
});
it("updates when the state dispatches errorChange", async () => {
const { result } = renderHook(() => useManagedListError(state));
client.listAllTools.mockRejectedValueOnce(boom);
await act(async () => {
await expect(state.refresh()).rejects.toThrow(boom);
});
expect(result.current).toBe(boom);
});
it("clears when a later load succeeds", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const { result } = renderHook(() => useManagedListError(state));
expect(result.current).toBe(boom);
await act(async () => {
await state.refresh();
});
expect(result.current).toBeNull();
});
it("resets to null when the state becomes null", async () => {
client.listAllTools.mockRejectedValueOnce(boom);
await expect(state.refresh()).rejects.toThrow(boom);
const { result, rerender } = renderHook(
({ s }: { s: ManagedToolsState | null }) => useManagedListError(s),
{ initialProps: { s: state as ManagedToolsState | null } },
);
expect(result.current).toBe(boom);
rerender({ s: null });
expect(result.current).toBeNull();
});
it("unsubscribes on unmount", async () => {
const { unmount } = renderHook(() => useManagedListError(state));
unmount();
client.listAllTools.mockRejectedValueOnce(boom);
// No act() wrapper: a listener still attached would warn about an update
// outside act, and the assertion below would be the only other signal.
await expect(state.refresh()).rejects.toThrow(boom);
expect(state.getError()).toBe(boom);
});
});