diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 9a96ce70f..fd18ac42b 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -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, @@ -402,6 +403,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType = { clientCapabilities: {}, serverInfo: undefined, instructions: undefined, + lastError: undefined, appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), @@ -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(); + + 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; diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 215712767..28dae33f4 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -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(undefined); + const lastConnectionErrorToastRef = useRef(undefined); // Per-task progress, keyed by taskId. Sourced from the core `requestorTaskProgress` // event (emitted by callToolStream, which owns the taskId), fed to the Tasks @@ -616,6 +617,7 @@ function App() { serverInfo, instructions, protocolVersion, + lastError, } = useInspectorClient(inspectorClient); const { tools, @@ -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 diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx index 7fddd5f28..dc891b4d7 100644 --- a/clients/web/src/test/core/react/useInspectorClient.test.tsx +++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx @@ -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(); }); @@ -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(); }); @@ -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)); @@ -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", () => { diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index e702b2669..beb2ee199 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -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. diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts index de873f7c6..a68cde6ef 100644 --- a/core/react/useInspectorClient.ts +++ b/core/react/useInspectorClient.ts @@ -22,6 +22,7 @@ export interface UseInspectorClientResult { serverInfo?: Implementation; instructions?: string; protocolVersion?: string; + lastError?: string; appRendererClient: AppRendererClient | null; connect: () => Promise; disconnect: () => Promise; @@ -58,6 +59,7 @@ export function useInspectorClient( const [protocolVersion, setProtocolVersion] = useState( inspectorClient?.getProtocolVersion(), ); + const [lastError, setLastError] = useState(); useEffect(() => { if (!inspectorClient) { @@ -66,6 +68,7 @@ export function useInspectorClient( setServerInfo(undefined); setInstructions(undefined); setProtocolVersion(undefined); + setLastError(undefined); return; } @@ -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); @@ -94,6 +104,7 @@ export function useInspectorClient( }; inspectorClient.addEventListener("statusChange", onStatusChange); + inspectorClient.addEventListener("error", onError); inspectorClient.addEventListener( "capabilitiesChange", onCapabilitiesChange, @@ -110,6 +121,7 @@ export function useInspectorClient( return () => { inspectorClient.removeEventListener("statusChange", onStatusChange); + inspectorClient.removeEventListener("error", onError); inspectorClient.removeEventListener( "capabilitiesChange", onCapabilitiesChange, @@ -152,6 +164,7 @@ export function useInspectorClient( serverInfo, instructions, protocolVersion, + lastError, appRendererClient: inspectorClient?.getAppRendererClient() ?? null, connect, disconnect,