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:
parent
097bfafdc1
commit
f201032137
7 changed files with 1080 additions and 48 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
87
clients/web/src/test/core/react/useManagedListError.test.tsx
Normal file
87
clients/web/src/test/core/react/useManagedListError.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue