Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions clients/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
status: "connected",
capabilities: {},
clientCapabilities: {},
lastError: undefined,
// Left undefined so `initializeResult` stays undefined and the
// ConnectionInfoModal (gated on it) never mounts during the test.
serverInfo: undefined,
Expand Down Expand Up @@ -402,6 +403,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
clientCapabilities: {},
serverInfo: undefined,
instructions: undefined,
lastError: undefined,
appRendererClient: null,
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -621,6 +623,35 @@ describe("App tool progress toasts", () => {
});
});

describe("App connection error toasts", () => {
beforeEach(() => {
notificationsMock.show.mockClear();
vi.mocked(useInspectorClient).mockReturnValue({
...DEFAULT_USE_INSPECTOR_CLIENT,
status: "error",
lastError: "stream closed",
});
});

afterEach(() => {
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
});

it("shows the current InspectorClient error reason", async () => {
renderWithMantine(<App />);

await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection error",
message: "stream closed",
color: "red",
}),
),
);
});
});

describe("App pending server-initiated request modal", () => {
beforeEach(() => {
clientInstances.length = 0;
Expand Down
19 changes: 19 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ function App() {
// stray task cancellation. A ref (not state) because it's only read at the
// moment Cancel is clicked and must not trigger re-renders.
const activeToolCallTaskIdRef = useRef<string | undefined>(undefined);
const lastConnectionErrorToastRef = useRef<string | undefined>(undefined);

// Per-task progress, keyed by taskId. Sourced from the core `requestorTaskProgress`
// event (emitted by callToolStream, which owns the taskId), fed to the Tasks
Expand All @@ -616,6 +617,7 @@ function App() {
serverInfo,
instructions,
protocolVersion,
lastError,
} = useInspectorClient(inspectorClient);
const {
tools,
Expand Down Expand Up @@ -991,6 +993,23 @@ function App() {
[servers, activeServerId],
);

useEffect(() => {
if (connectionStatus !== "error") {
lastConnectionErrorToastRef.current = undefined;
return;
}
if (!lastError) return;
if (lastConnectionErrorToastRef.current === lastError) return;
lastConnectionErrorToastRef.current = lastError;
notifications.show({
title: activeServer
? `Connection error for "${activeServer.name}"`
: "Connection error",
message: lastError,
color: "red",
});
}, [activeServer, connectionStatus, lastError]);

// `config.type` is optional in the schema (a bare `command: ...`
// entry implies stdio), so we materialize the default here rather
// than at the render site — the modal's `transport` prop is a
Expand Down
22 changes: 22 additions & 0 deletions clients/web/src/test/core/react/useInspectorClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("useInspectorClient", () => {
expect(result.current.serverInfo).toEqual(SERVER_INFO);
expect(result.current.instructions).toBe("hello");
expect(result.current.protocolVersion).toBe("2025-06-18");
expect(result.current.lastError).toBeUndefined();
expect(result.current.appRendererClient).toBeNull();
});

Expand All @@ -37,6 +38,7 @@ describe("useInspectorClient", () => {
expect(result.current.serverInfo).toBeUndefined();
expect(result.current.instructions).toBeUndefined();
expect(result.current.protocolVersion).toBeUndefined();
expect(result.current.lastError).toBeUndefined();
expect(result.current.appRendererClient).toBeNull();
});

Expand All @@ -54,6 +56,25 @@ describe("useInspectorClient", () => {
expect(result.current.status).toBe("connected");
});

it("subscribes to error events and clears lastError after recovery", () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.lastError).toBeUndefined();

act(() => {
client.setStatus("error");
client.dispatchTypedEvent("error", new Error("stream died"));
});
expect(result.current.status).toBe("error");
expect(result.current.lastError).toBe("stream died");

act(() => {
client.setStatus("connected");
});
expect(result.current.status).toBe("connected");
expect(result.current.lastError).toBeUndefined();
});

it("subscribes to capabilities/serverInfo/instructions changes", () => {
const client = new FakeInspectorClient();
const { result } = renderHook(() => useInspectorClient(client));
Expand Down Expand Up @@ -111,6 +132,7 @@ describe("useInspectorClient", () => {
rerender({ c: null });
expect(result.current.status).toBe("disconnected");
expect(result.current.capabilities).toBeUndefined();
expect(result.current.lastError).toBeUndefined();
});

it("re-subscribes when the client prop changes", () => {
Expand Down
11 changes: 11 additions & 0 deletions clients/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ const nodeModulesAliases = [
// Vitest's transformer pipeline — the mock then fails to intercept the
// source-side import (see #1307).
{ find: /^@modelcontextprotocol\/sdk\/client\/auth\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/client/auth.js') },
{ find: /^@modelcontextprotocol\/sdk\/client\/index\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/client/index.js') },
// Unit tests run from repoRoot, which has no node_modules. Core modules that
// import SDK subpaths need the same clients/web-pinned resolution as auth.js
// above.
{ find: /^@modelcontextprotocol\/sdk\/shared\/auth\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/shared/auth.js') },
{ find: /^@modelcontextprotocol\/sdk\/shared\/protocol\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js') },
{ find: /^@modelcontextprotocol\/sdk\/shared\/transport\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/shared/transport.js') },
{ find: /^@modelcontextprotocol\/sdk\/shared\/uriTemplate\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/shared/uriTemplate.js') },
{ find: /^@modelcontextprotocol\/sdk\/types\.js$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/types.js') },
{ find: /^@modelcontextprotocol\/sdk\/validation$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/validation/index.js') },
{ find: /^@modelcontextprotocol\/sdk\/validation\/ajv$/, replacement: path.resolve(dirname, 'node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js') },
];

// Project resolve config shared between the unit and integration projects.
Expand Down
13 changes: 13 additions & 0 deletions core/react/useInspectorClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface UseInspectorClientResult {
serverInfo?: Implementation;
instructions?: string;
protocolVersion?: string;
lastError?: string;
appRendererClient: AppRendererClient | null;
connect: () => Promise<void>;
disconnect: () => Promise<void>;
Expand Down Expand Up @@ -58,6 +59,7 @@ export function useInspectorClient(
const [protocolVersion, setProtocolVersion] = useState<string | undefined>(
inspectorClient?.getProtocolVersion(),
);
const [lastError, setLastError] = useState<string | undefined>();

useEffect(() => {
if (!inspectorClient) {
Expand All @@ -66,6 +68,7 @@ export function useInspectorClient(
setServerInfo(undefined);
setInstructions(undefined);
setProtocolVersion(undefined);
setLastError(undefined);
return;
}

Expand All @@ -74,9 +77,16 @@ export function useInspectorClient(
setServerInfo(inspectorClient.getServerInfo());
setInstructions(inspectorClient.getInstructions());
setProtocolVersion(inspectorClient.getProtocolVersion());
setLastError(undefined);

const onStatusChange = (event: TypedEvent<"statusChange">) => {
setStatus(event.detail);
if (event.detail !== "error") {
setLastError(undefined);
}
};
const onError = (event: TypedEvent<"error">) => {
setLastError(event.detail.message);
};
const onCapabilitiesChange = (event: TypedEvent<"capabilitiesChange">) => {
setCapabilities(event.detail);
Expand All @@ -94,6 +104,7 @@ export function useInspectorClient(
};

inspectorClient.addEventListener("statusChange", onStatusChange);
inspectorClient.addEventListener("error", onError);
inspectorClient.addEventListener(
"capabilitiesChange",
onCapabilitiesChange,
Expand All @@ -110,6 +121,7 @@ export function useInspectorClient(

return () => {
inspectorClient.removeEventListener("statusChange", onStatusChange);
inspectorClient.removeEventListener("error", onError);
inspectorClient.removeEventListener(
"capabilitiesChange",
onCapabilitiesChange,
Expand Down Expand Up @@ -152,6 +164,7 @@ export function useInspectorClient(
serverInfo,
instructions,
protocolVersion,
lastError,
appRendererClient: inspectorClient?.getAppRendererClient() ?? null,
connect,
disconnect,
Expand Down