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
7 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);
});
});

View file

@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { MessageLogState } from "@inspector/core/mcp/state/index.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
/**
* `markResponseRejected` correlation (#1953).
*
* A response the server answered successfully but the client then refused (the
* SDK codec rejecting the result for the negotiated era) carries no request id
* in the SDK's error, so the client recovers it from the last response received
* for that method. These tests drive a REAL connection so the correlation runs
* against real JSON-RPC ids assigned by the SDK, not hand-built fixtures.
*/
describe("InspectorClient.markResponseRejected", () => {
let client: InspectorClient | null = null;
let log: MessageLogState | null = null;
beforeEach(async () => {
const serverCommand = getTestMcpServerCommand();
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
log = new MessageLogState(client);
await client.connect();
});
afterEach(async () => {
log?.destroy();
log = null;
try {
await client?.disconnect();
} catch {
// Ignore teardown failures — the assertion already ran.
}
client = null;
});
function entriesFor(method: string): MessageEntry[] {
return (log?.getMessages() ?? []).filter(
(entry) => "method" in entry.message && entry.message.method === method,
);
}
it("annotates the entry for the method's most recent response", async () => {
await client!.listTools();
const before = entriesFor("tools/list");
expect(before).toHaveLength(1);
expect(before[0]?.clientError).toBeUndefined();
client!.markResponseRejected("tools/list", "ttlMs required");
expect(entriesFor("tools/list")[0]?.clientError).toBe("ttlMs required");
});
it("annotates only the latest call, leaving earlier ones untouched", async () => {
await client!.listTools();
await client!.listTools();
const entries = entriesFor("tools/list");
expect(entries).toHaveLength(2);
client!.markResponseRejected("tools/list", "second one failed");
expect(entries[0]?.clientError).toBeUndefined();
expect(entries[1]?.clientError).toBe("second one failed");
});
it("does not cross method boundaries", async () => {
await client!.listTools();
await client!.listPrompts();
client!.markResponseRejected("tools/list", "tools failed");
expect(entriesFor("tools/list")[0]?.clientError).toBe("tools failed");
expect(entriesFor("prompts/list")[0]?.clientError).toBeUndefined();
});
it("is a no-op for a method nothing has answered", () => {
// No throw, and nothing annotated — a list that never reached the wire
// (e.g. a capability-gated one) must not mislabel some other entry.
expect(() =>
client!.markResponseRejected("resources/list", "never happened"),
).not.toThrow();
expect((log?.getMessages() ?? []).some((entry) => entry.clientError)).toBe(
false,
);
});
});