From 2e8288126b36f81fcd23976a8734047634a2a68d Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 11 Mar 2026 14:58:28 -0700 Subject: [PATCH 1/3] Spike: ManagedToolsState with Zustand store + getStore(), useManagedTools(manager only) Made-with: Cursor --- clients/tui/src/App.tsx | 897 +++++++++--------- .../mcp/state/managedToolsState.test.ts | 50 +- core/__tests__/react/useManagedTools.test.tsx | 92 +- core/mcp/state/index.ts | 2 +- core/mcp/state/managedToolsState.ts | 44 +- core/react/useManagedTools.ts | 52 +- 6 files changed, 565 insertions(+), 572 deletions(-) diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 6950361f3..713e1ddbc 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -35,7 +35,10 @@ import { } from "@modelcontextprotocol/inspector-core/mcp/state/index.js"; import { createTransportNode } from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; import { useInspectorClient } from "@modelcontextprotocol/inspector-core/react/useInspectorClient.js"; -import { useManagedTools } from "@modelcontextprotocol/inspector-core/react/useManagedTools.js"; +import { + useManagedTools, + type UseManagedToolsResult, +} from "@modelcontextprotocol/inspector-core/react/useManagedTools.js"; import { useManagedResources } from "@modelcontextprotocol/inspector-core/react/useManagedResources.js"; import { useManagedResourceTemplates } from "@modelcontextprotocol/inspector-core/react/useManagedResourceTemplates.js"; import { useManagedPrompts } from "@modelcontextprotocol/inspector-core/react/useManagedPrompts.js"; @@ -68,6 +71,21 @@ import { ResourceTestModal } from "./components/ResourceTestModal.js"; import { PromptTestModal } from "./components/PromptTestModal.js"; import { DetailsModal } from "./components/DetailsModal.js"; +/** Syncs useManagedTools(manager) result to parent so the hook is only called when manager exists. */ +function ManagedToolsSync({ + manager, + onResult, +}: { + manager: ManagedToolsState; + onResult: (r: UseManagedToolsResult) => void; +}) { + const result = useManagedTools(manager); + useEffect(() => { + onResult(result); + }, [result, onResult]); + return null; +} + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -204,7 +222,7 @@ function App({ const [inspectorClients, setInspectorClients] = useState< Record >({}); - // ManagedToolsState per server (tools list from manager, not client) + // ManagedToolsState per server (tools list from manager) const [managedToolsStates, setManagedToolsStates] = useState< Record >({}); @@ -455,7 +473,7 @@ function App({ selectedStderrLogState, ); - // Tools from ManagedToolsState (full list, auto-load on connect) + // Tools from ManagedToolsState (full list, auto-load on connect). Hook is only called when we have a manager (via ManagedToolsSync). const selectedManagedToolsState = useMemo( () => selectedServer && managedToolsStates[selectedServer] @@ -463,10 +481,14 @@ function App({ : null, [selectedServer, managedToolsStates], ); - const { tools: managedTools } = useManagedTools( - selectedInspectorClient, - selectedManagedToolsState, - ); + const [managedToolsResult, setManagedToolsResult] = + useState({ tools: [], refresh: async () => [] }); + useEffect(() => { + if (!selectedManagedToolsState) { + setManagedToolsResult({ tools: [], refresh: async () => [] }); + } + }, [selectedManagedToolsState]); + const managedTools = managedToolsResult.tools; // Resources, resource templates, prompts from managed state managers const selectedManagedResourcesState = useMemo( @@ -776,7 +798,7 @@ function App({ } }, [selectedInspectorClient]); - // Build current server state from InspectorClient data (tools from ManagedToolsState) + // Build current server state from InspectorClient data (tools from managed tools store) const currentServerState = useMemo(() => { if (!selectedServer) return null; return { @@ -1361,468 +1383,477 @@ function App({ }; return ( - - {/* Header row across the top */} - - - - {packageJson.name} - - - {packageJson.description} - - v{packageJson.version} - - - {/* Main content area */} + <> + {selectedManagedToolsState ? ( + + ) : null} - {/* Left column - Server list */} + {/* Header row across the top */} - - - MCP Servers - - - - {serverNames.map((serverName) => { - const isSelected = selectedServer === serverName; - return ( - - - {isSelected ? "▶ " : " "} - {serverName} - - - ); - })} - - - {/* Fixed footer */} - - - ESC to exit + + + {packageJson.name} + - {packageJson.description} + v{packageJson.version} - {/* Right column - Server details, Tabs and content */} + {/* Main content area */} - {/* Server Details - Flexible height */} + {/* Left column - Server list */} - - + - - {selectedServer} - - - {currentServerState && ( - <> - - {getStatusSymbol(currentServerState.status)}{" "} - {currentServerState.status} - - - {(currentServerState?.status === "disconnected" || - currentServerState?.status === "error") && ( - - [Connect] - - )} - {(currentServerState?.status === "connected" || - currentServerState?.status === "connecting") && ( - - [Disconnect] - - )} - - )} - - - {show401AuthHint && ( - - - 401 Unauthorized. Press A to authenticate. - - - )} - {oauthStatus !== "idle" && ( - - {oauthStatus === "authenticating" && ( - OAuth: authenticating… - )} - {oauthStatus === "success" && oauthMessage && ( - {oauthMessage} - )} - {oauthStatus === "error" && oauthMessage && ( - OAuth: {oauthMessage} - )} - - )} + MCP Servers + + + + {serverNames.map((serverName) => { + const isSelected = selectedServer === serverName; + return ( + + + {isSelected ? "▶ " : " "} + {serverName} + + + ); + })} - - {/* Tabs */} - { - const serverType = - inspectorClients[selectedServer].getServerType(); - return ( - serverType === "sse" || serverType === "streamable-http" - ); - })() - : false - } - /> + {/* Fixed footer */} + + + ESC to exit + + + - {/* Tab Content */} + {/* Right column - Server details, Tabs and content */} - {activeTab === "info" && ( - - )} - {activeTab === "auth" && - selectedServer && - selectedServerConfig && - isOAuthCapableServer(selectedServerConfig) ? ( - - ) : null} - {activeTab === "resources" && - currentServerState?.status === "connected" && - selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, resources: count })) - } - focusedPane={ - focus === "tabContentDetails" - ? "details" - : focus === "tabContentList" - ? "list" - : null - } - onViewDetails={(resource) => - setDetailsModal({ - title: `Resource: ${"uri" in resource ? resource.name || resource.uri || "Unknown" : "Resource content"}`, - content: renderResourceDetails(resource), - }) - } - onFetchResource={() => { - // Resource fetching is handled internally by ResourcesTab - // This callback is just for triggering the fetch - }} - onFetchTemplate={(template) => { - setResourceTestModal({ - template, - inspectorClient: selectedInspectorClient, - }); - }} - modalOpen={ - !!(toolTestModal || resourceTestModal || detailsModal) - } - /> - ) : activeTab === "prompts" && - currentServerState?.status === "connected" && - selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, prompts: count })) - } - focusedPane={ - focus === "tabContentDetails" - ? "details" - : focus === "tabContentList" - ? "list" - : null - } - onViewDetails={(prompt) => - setDetailsModal({ - title: `Prompt: ${prompt.name || "Unknown"}`, - content: renderPromptDetails(prompt), - }) - } - onFetchPrompt={(prompt) => { - setPromptTestModal({ - prompt, - inspectorClient: selectedInspectorClient, - }); - }} - modalOpen={ - !!( - toolTestModal || - resourceTestModal || - promptTestModal || - detailsModal - ) - } - /> - ) : activeTab === "tools" && + {/* Server Details - Flexible height */} + + + + + {selectedServer} + + + {currentServerState && ( + <> + + {getStatusSymbol(currentServerState.status)}{" "} + {currentServerState.status} + + + {(currentServerState?.status === "disconnected" || + currentServerState?.status === "error") && ( + + [Connect] + + )} + {(currentServerState?.status === "connected" || + currentServerState?.status === "connecting") && ( + + [Disconnect] + + )} + + )} + + + {show401AuthHint && ( + + + 401 Unauthorized. Press A to + authenticate. + + + )} + {oauthStatus !== "idle" && ( + + {oauthStatus === "authenticating" && ( + OAuth: authenticating… + )} + {oauthStatus === "success" && oauthMessage && ( + {oauthMessage} + )} + {oauthStatus === "error" && oauthMessage && ( + OAuth: {oauthMessage} + )} + + )} + + + + {/* Tabs */} + { + const serverType = + inspectorClients[selectedServer].getServerType(); + return ( + serverType === "sse" || serverType === "streamable-http" + ); + })() + : false + } + /> + + {/* Tab Content */} + + {activeTab === "info" && ( + + )} + {activeTab === "auth" && + selectedServer && + selectedServerConfig && + isOAuthCapableServer(selectedServerConfig) ? ( + + ) : null} + {activeTab === "resources" && currentServerState?.status === "connected" && selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, tools: count })) - } - focusedPane={ - focus === "tabContentDetails" - ? "details" - : focus === "tabContentList" - ? "list" - : null - } - onTestTool={(tool) => - setToolTestModal({ - tool, - inspectorClient: selectedInspectorClient, - }) - } - onViewDetails={(tool) => - setDetailsModal({ - title: `Tool: ${tool.name || "Unknown"}`, - content: renderToolDetails(tool), - }) - } - modalOpen={!!(toolTestModal || detailsModal)} - /> - ) : activeTab === "messages" && selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, messages: count })) - } - focusedPane={ - focus === "messagesDetail" - ? "details" - : focus === "messagesList" - ? "messages" - : null - } - modalOpen={!!(toolTestModal || detailsModal)} - onViewDetails={(message) => { - const label = - message.direction === "request" && - "method" in message.message - ? message.message.method - : message.direction === "response" - ? "Response" - : message.direction === "notification" && - "method" in message.message - ? message.message.method - : "Message"; - setDetailsModal({ - title: `Message: ${label}`, - content: renderMessageDetails(message), - }); - }} - /> - ) : activeTab === "requests" && - selectedInspectorClient && - (inspectorStatus === "connected" || - inspectorFetchRequests.length > 0) ? ( - - setTabCounts((prev) => ({ ...prev, requests: count })) - } - focusedPane={ - focus === "requestsDetail" - ? "details" - : focus === "requestsList" - ? "requests" - : null - } - modalOpen={!!(toolTestModal || detailsModal)} - onViewDetails={(request) => { - setDetailsModal({ - title: `Request: ${request.method} ${request.url}`, - content: renderRequestDetails(request), - }); - }} - /> - ) : activeTab === "logging" && selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, logging: count })) - } - focused={ - focus === "tabContentList" || focus === "tabContentDetails" - } - /> - ) : null} + + setTabCounts((prev) => ({ ...prev, resources: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onViewDetails={(resource) => + setDetailsModal({ + title: `Resource: ${"uri" in resource ? resource.name || resource.uri || "Unknown" : "Resource content"}`, + content: renderResourceDetails(resource), + }) + } + onFetchResource={() => { + // Resource fetching is handled internally by ResourcesTab + // This callback is just for triggering the fetch + }} + onFetchTemplate={(template) => { + setResourceTestModal({ + template, + inspectorClient: selectedInspectorClient, + }); + }} + modalOpen={ + !!(toolTestModal || resourceTestModal || detailsModal) + } + /> + ) : activeTab === "prompts" && + currentServerState?.status === "connected" && + selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, prompts: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onViewDetails={(prompt) => + setDetailsModal({ + title: `Prompt: ${prompt.name || "Unknown"}`, + content: renderPromptDetails(prompt), + }) + } + onFetchPrompt={(prompt) => { + setPromptTestModal({ + prompt, + inspectorClient: selectedInspectorClient, + }); + }} + modalOpen={ + !!( + toolTestModal || + resourceTestModal || + promptTestModal || + detailsModal + ) + } + /> + ) : activeTab === "tools" && + currentServerState?.status === "connected" && + selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, tools: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onTestTool={(tool) => + setToolTestModal({ + tool, + inspectorClient: selectedInspectorClient, + }) + } + onViewDetails={(tool) => + setDetailsModal({ + title: `Tool: ${tool.name || "Unknown"}`, + content: renderToolDetails(tool), + }) + } + modalOpen={!!(toolTestModal || detailsModal)} + /> + ) : activeTab === "messages" && selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, messages: count })) + } + focusedPane={ + focus === "messagesDetail" + ? "details" + : focus === "messagesList" + ? "messages" + : null + } + modalOpen={!!(toolTestModal || detailsModal)} + onViewDetails={(message) => { + const label = + message.direction === "request" && + "method" in message.message + ? message.message.method + : message.direction === "response" + ? "Response" + : message.direction === "notification" && + "method" in message.message + ? message.message.method + : "Message"; + setDetailsModal({ + title: `Message: ${label}`, + content: renderMessageDetails(message), + }); + }} + /> + ) : activeTab === "requests" && + selectedInspectorClient && + (inspectorStatus === "connected" || + inspectorFetchRequests.length > 0) ? ( + + setTabCounts((prev) => ({ ...prev, requests: count })) + } + focusedPane={ + focus === "requestsDetail" + ? "details" + : focus === "requestsList" + ? "requests" + : null + } + modalOpen={!!(toolTestModal || detailsModal)} + onViewDetails={(request) => { + setDetailsModal({ + title: `Request: ${request.method} ${request.url}`, + content: renderRequestDetails(request), + }); + }} + /> + ) : activeTab === "logging" && selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, logging: count })) + } + focused={ + focus === "tabContentList" || focus === "tabContentDetails" + } + /> + ) : null} + - - {/* Tool Test Modal - rendered at App level for full screen overlay */} - {toolTestModal && ( - setToolTestModal(null)} - /> - )} + {/* Tool Test Modal - rendered at App level for full screen overlay */} + {toolTestModal && ( + setToolTestModal(null)} + /> + )} - {/* Resource Test Modal - rendered at App level for full screen overlay */} - {resourceTestModal && ( - setResourceTestModal(null)} - /> - )} + {/* Resource Test Modal - rendered at App level for full screen overlay */} + {resourceTestModal && ( + setResourceTestModal(null)} + /> + )} - {promptTestModal && ( - setPromptTestModal(null)} - /> - )} + {promptTestModal && ( + setPromptTestModal(null)} + /> + )} - {/* Details Modal - rendered at App level for full screen overlay */} - {detailsModal && ( - setDetailsModal(null)} - /> - )} - + {/* Details Modal - rendered at App level for full screen overlay */} + {detailsModal && ( + setDetailsModal(null)} + /> + )} + + ); } diff --git a/core/__tests__/mcp/state/managedToolsState.test.ts b/core/__tests__/mcp/state/managedToolsState.test.ts index d4de3181d..82c30e996 100644 --- a/core/__tests__/mcp/state/managedToolsState.test.ts +++ b/core/__tests__/mcp/state/managedToolsState.test.ts @@ -15,6 +15,24 @@ import { type TestServerHttp, } from "@modelcontextprotocol/inspector-test-server"; +function waitForTools(manager: ManagedToolsState): Promise { + return new Promise((resolve) => { + const store = manager.getStore(); + const tools = store.getState().tools; + if (tools.length > 0) { + resolve(tools); + return; + } + const unsub = store.subscribe(() => { + const next = store.getState().tools; + if (next.length > 0) { + unsub(); + resolve(next); + } + }); + }); +} + describe("ManagedToolsState", () => { let client: InspectorClient | null = null; let server: TestServerHttp | null = null; @@ -37,14 +55,6 @@ describe("ManagedToolsState", () => { } }); - function waitForToolsChange(s: ManagedToolsState): Promise { - return new Promise((resolve) => { - s.addEventListener("toolsChange", (e) => resolve(e.detail), { - once: true, - }); - }); - } - it("starts with empty tools before connect", () => { client = new InspectorClient( { type: "streamable-http", url: "http://localhost:0" }, @@ -54,7 +64,7 @@ describe("ManagedToolsState", () => { expect(state.getTools()).toEqual([]); }); - it("on connect loads initial tools and dispatches toolsChange", async () => { + it("on connect loads initial tools and updates store", async () => { server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], @@ -67,16 +77,15 @@ describe("ManagedToolsState", () => { }, ); state = new ManagedToolsState(client); - const toolsPromise = waitForToolsChange(state); + const toolsPromise = waitForTools(state); await client.connect(); const tools = await toolsPromise; expect(tools.length).toBeGreaterThan(0); expect(tools.some((t) => t.name === "echo")).toBe(true); - expect(state.getTools()).toEqual(tools); + expect(state!.getTools()).toEqual(tools); }); - it("refresh fetches all pages and dispatches toolsChange", async () => { - // Same server config as inspectorClient.test "should accumulate tools when paginating with cursor" + it("refresh fetches all pages and updates store", async () => { server = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: createNumberedTools(6), @@ -95,10 +104,9 @@ describe("ManagedToolsState", () => { ); await client.connect(); - // Manager refresh must see exactly 6 tools (uses listTools(), so no list interactions) state = new ManagedToolsState(client); - const toolsPromise = waitForToolsChange(state); - const tools = await state.refresh(); + const toolsPromise = waitForTools(state); + const tools = await state!.refresh(); await toolsPromise; expect(tools).toHaveLength(6); expect(tools.map((t) => t.name)).toEqual([ @@ -109,7 +117,7 @@ describe("ManagedToolsState", () => { "tool_5", "tool_6", ]); - expect(state.getTools()).toEqual(tools); + expect(state!.getTools()).toEqual(tools); }); it("on toolsListChanged refreshes and updates tools", async () => { @@ -129,18 +137,18 @@ describe("ManagedToolsState", () => { ); state = new ManagedToolsState(client); await client.connect(); - await waitForToolsChange(state!); + await waitForTools(state!); const toolsBefore = state!.getTools(); expect(toolsBefore.length).toBeGreaterThan(0); const addTool = state!.getTools().find((t) => t.name === "add_tool"); expect(addTool).toBeDefined(); - const toolsChangePromise = waitForToolsChange(state!); + const toolsPromise = waitForTools(state!); await client!.callTool(addTool!, { name: "newTool", description: "A new test tool", }); - await toolsChangePromise; + await toolsPromise; const toolsAfter = state!.getTools(); expect(toolsAfter.find((t) => t.name === "newTool")).toBeDefined(); }); @@ -159,7 +167,7 @@ describe("ManagedToolsState", () => { ); state = new ManagedToolsState(client); await client.connect(); - await waitForToolsChange(state!); + await waitForTools(state!); expect(state!.getTools().length).toBeGreaterThan(0); await client!.disconnect(100); expect(state!.getTools()).toEqual([]); diff --git a/core/__tests__/react/useManagedTools.test.tsx b/core/__tests__/react/useManagedTools.test.tsx index c4243423b..1c7cda4a6 100644 --- a/core/__tests__/react/useManagedTools.test.tsx +++ b/core/__tests__/react/useManagedTools.test.tsx @@ -4,84 +4,71 @@ import { describe, it, expect } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useManagedTools } from "../../react/useManagedTools.js"; +import { createStore, type StoreApi } from "zustand/vanilla"; import type { ManagedToolsState } from "../../mcp/state/managedToolsState.js"; -import type { InspectorClient } from "../../mcp/inspectorClient.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; /** - * Mock ManagedToolsState: getTools(), refresh(), and toolsChange events. + * Mock ManagedToolsState: getStore(), getTools(), refresh(), setMetadata(), destroy(). + * Not typed as implements ManagedToolsState because the real class has private fields. */ -class MockManagedToolsState extends EventTarget { - private _tools: Tool[] = []; +class MockManagedToolsState { + private store: StoreApi<{ tools: Tool[] }>; + + constructor() { + this.store = createStore<{ tools: Tool[] }>()((_set) => ({ tools: [] })); + } + + getStore() { + return { + getState: () => this.store.getState(), + subscribe: (listener: () => void) => this.store.subscribe(listener), + }; + } getTools(): Tool[] { - return [...this._tools]; + return this.store.getState().tools; } setTools(tools: Tool[]): void { - this._tools = tools; - this.dispatchEvent(new CustomEvent("toolsChange", { detail: tools })); + this.store.setState({ tools }); } + setMetadata(): void {} + async refresh(): Promise { return this.getTools(); } destroy(): void { - this._tools = []; + this.store.setState({ tools: [] }); } } describe("useManagedTools", () => { - it("returns empty tools and no-op refresh when given null client and null manager", async () => { - const { result } = renderHook(() => useManagedTools(null, null)); - - expect(result.current.tools).toEqual([]); - - await act(async () => { - const next = await result.current.refresh(); - expect(next).toEqual([]); - }); - expect(result.current.tools).toEqual([]); - }); - - it("returns empty tools when manager is null", async () => { - const client = {} as InspectorClient; - const { result } = renderHook(() => useManagedTools(client, null)); - - expect(result.current.tools).toEqual([]); - - await act(async () => { - const next = await result.current.refresh(); - expect(next).toEqual([]); - }); - }); - - it("syncs initial tools from manager", () => { + it("syncs initial tools from manager store", () => { const manager = new MockManagedToolsState(); manager.setTools([ { name: "a", inputSchema: { type: "object" as const } }, { name: "b", inputSchema: { type: "object" as const } }, ]); - const client = {} as InspectorClient; const { result } = renderHook(() => - useManagedTools(client, manager as unknown as ManagedToolsState), + useManagedTools(manager as unknown as ManagedToolsState), ); expect(result.current.tools).toHaveLength(2); expect(result.current.tools.map((t) => t.name)).toEqual(["a", "b"]); }); - it("updates tools when manager dispatches toolsChange", async () => { + it("updates tools when manager store updates", async () => { const manager = new MockManagedToolsState(); manager.setTools([ { name: "first", inputSchema: { type: "object" as const } }, ]); - const client = {} as InspectorClient; const { result } = renderHook(() => - useManagedTools(client, manager as unknown as ManagedToolsState), + useManagedTools(manager as unknown as ManagedToolsState), ); expect(result.current.tools).toHaveLength(1); @@ -101,13 +88,12 @@ describe("useManagedTools", () => { ]); }); - it("refresh updates state from manager", async () => { + it("refresh returns tools from manager", async () => { const manager = new MockManagedToolsState(); manager.setTools([{ name: "x", inputSchema: { type: "object" as const } }]); - const client = {} as InspectorClient; const { result } = renderHook(() => - useManagedTools(client, manager as unknown as ManagedToolsState), + useManagedTools(manager as unknown as ManagedToolsState), ); expect(result.current.tools).toHaveLength(1); @@ -126,28 +112,4 @@ describe("useManagedTools", () => { expect(result.current.tools).toHaveLength(2); }); - - it("clears tools when manager switches to null", async () => { - const manager = new MockManagedToolsState(); - manager.setTools([ - { name: "only", inputSchema: { type: "object" as const } }, - ]); - const client = {} as InspectorClient; - - const { result, rerender } = renderHook( - ({ client: c, manager: m }) => useManagedTools(c, m), - { - initialProps: { - client, - manager: manager as unknown as ManagedToolsState, - }, - }, - ); - - expect(result.current.tools).toHaveLength(1); - - rerender({ client, manager: null }); - - expect(result.current.tools).toEqual([]); - }); }); diff --git a/core/mcp/state/index.ts b/core/mcp/state/index.ts index 7f1cb2a37..75ecd1f5c 100644 --- a/core/mcp/state/index.ts +++ b/core/mcp/state/index.ts @@ -1,5 +1,5 @@ export { ManagedToolsState } from "./managedToolsState.js"; -export type { ManagedToolsStateEventMap } from "./managedToolsState.js"; +export type { ManagedToolsReadOnlyStore } from "./managedToolsState.js"; export { MessageLogState } from "./messageLogState.js"; export type { MessageLogStateEventMap, diff --git a/core/mcp/state/managedToolsState.ts b/core/mcp/state/managedToolsState.ts index 7fa563458..9827c3223 100644 --- a/core/mcp/state/managedToolsState.ts +++ b/core/mcp/state/managedToolsState.ts @@ -1,31 +1,34 @@ /** - * ManagedToolsState: holds full tool list, syncs on toolsListChanged. - * Takes InspectorClient (will become InspectorClientProtocol in Stage 5). + * ManagedToolsState: keeps full tool list in sync with the server. + * State is held in a Zustand store; manager updates it and exposes getStore() for read-only subscription. */ +import { createStore } from "zustand/vanilla"; import type { InspectorClient } from "../inspectorClient.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { TypedEventTarget } from "../typedEventTarget.js"; const MAX_PAGES = 100; -export interface ManagedToolsStateEventMap { - toolsChange: Tool[]; +/** Read-only store view: getState + subscribe, no setState. Use for hooks and other consumers. */ +export interface ManagedToolsReadOnlyStore { + getState: () => { tools: Tool[] }; + subscribe: (listener: () => void) => () => void; } /** * State manager that keeps a full tool list in sync with the server. - * Subscribes to client's connect (initial load), toolsListChanged, and statusChange; fetches all pages on refresh. - * If the caller wants metadata on list_tools (e.g. CLI --metadata), set it via setMetadata() so internal refresh() calls use it. + * Subscribes to client's connect, toolsListChanged, and statusChange; fetches all pages on refresh. + * Use getStore() for subscription (e.g. in React via useStore); use getTools() for one-off read. */ -export class ManagedToolsState extends TypedEventTarget { - private tools: Tool[] = []; +export class ManagedToolsState { + private readonly store = createStore<{ tools: Tool[] }>()((_set) => ({ + tools: [], + })); private client: InspectorClient | null = null; private unsubscribe: (() => void) | null = null; private _metadata: Record | undefined = undefined; constructor(client: InspectorClient) { - super(); this.client = client; const onConnect = (): void => { void this.refresh(); @@ -35,8 +38,7 @@ export class ManagedToolsState extends TypedEventTarget { if (this.client?.getStatus() === "disconnected") { - this.tools = []; - this.dispatchTypedEvent("toolsChange", []); + this.store.setState({ tools: [] }); } }; this.client.addEventListener("connect", onConnect); @@ -52,8 +54,16 @@ export class ManagedToolsState extends TypedEventTarget s.tools)). */ + getStore(): ManagedToolsReadOnlyStore { + return { + getState: () => this.store.getState(), + subscribe: (listener) => this.store.subscribe(listener), + }; + } + getTools(): Tool[] { - return [...this.tools]; + return [...this.store.getState().tools]; } /** @@ -75,12 +85,12 @@ export class ManagedToolsState extends TypedEventTarget= MAX_PAGES) { @@ -89,7 +99,7 @@ export class ManagedToolsState extends TypedEventTarget Promise; + refresh: (metadata?: Record) => Promise; } /** - * React hook that subscribes to ManagedToolsState and returns tools + refresh. - * Pass the same InspectorClient and ManagedToolsState (or create manager inside hook via ref). + * Subscribes to the manager's store and returns tools + refresh. + * Requires a ManagedToolsState (only call when you have a manager). */ export function useManagedTools( - client: InspectorClient | null, - managedToolsState: ManagedToolsState | null, + managedToolsState: ManagedToolsState, ): UseManagedToolsResult { - const [tools, setTools] = useState( - managedToolsState?.getTools() ?? [], + const store = managedToolsState.getStore(); + const tools = useSyncExternalStore( + store.subscribe, + () => store.getState().tools, ); - useEffect(() => { - if (!managedToolsState) { - setTools([]); - return; - } - setTools(managedToolsState.getTools()); - const onToolsChange = ( - event: TypedEventGeneric, - ) => { - setTools(event.detail); - }; - managedToolsState.addEventListener("toolsChange", onToolsChange); - return () => { - managedToolsState.removeEventListener("toolsChange", onToolsChange); - }; - }, [managedToolsState]); - - const refresh = useCallback(async (): Promise => { - if (!managedToolsState || !client) return []; - const next = await managedToolsState.refresh(); - setTools(next); - return next; - }, [client, managedToolsState]); + const refresh = useCallback( + async (metadata?: Record): Promise => { + return managedToolsState.refresh(metadata); + }, + [managedToolsState], + ); return { tools, refresh }; } From ddfa18715d641bf216a3fff7802a440f41d566f7 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Wed, 11 Mar 2026 15:31:36 -0700 Subject: [PATCH 2/3] Added handling for no state manager in hook --- clients/tui/src/App.tsx | 890 +++++++++--------- core/__tests__/react/useManagedTools.test.tsx | 14 + core/react/useManagedTools.ts | 16 +- 3 files changed, 451 insertions(+), 469 deletions(-) diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 713e1ddbc..c9029276f 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -35,10 +35,7 @@ import { } from "@modelcontextprotocol/inspector-core/mcp/state/index.js"; import { createTransportNode } from "@modelcontextprotocol/inspector-core/mcp/node/index.js"; import { useInspectorClient } from "@modelcontextprotocol/inspector-core/react/useInspectorClient.js"; -import { - useManagedTools, - type UseManagedToolsResult, -} from "@modelcontextprotocol/inspector-core/react/useManagedTools.js"; +import { useManagedTools } from "@modelcontextprotocol/inspector-core/react/useManagedTools.js"; import { useManagedResources } from "@modelcontextprotocol/inspector-core/react/useManagedResources.js"; import { useManagedResourceTemplates } from "@modelcontextprotocol/inspector-core/react/useManagedResourceTemplates.js"; import { useManagedPrompts } from "@modelcontextprotocol/inspector-core/react/useManagedPrompts.js"; @@ -71,21 +68,6 @@ import { ResourceTestModal } from "./components/ResourceTestModal.js"; import { PromptTestModal } from "./components/PromptTestModal.js"; import { DetailsModal } from "./components/DetailsModal.js"; -/** Syncs useManagedTools(manager) result to parent so the hook is only called when manager exists. */ -function ManagedToolsSync({ - manager, - onResult, -}: { - manager: ManagedToolsState; - onResult: (r: UseManagedToolsResult) => void; -}) { - const result = useManagedTools(manager); - useEffect(() => { - onResult(result); - }, [result, onResult]); - return null; -} - const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -473,7 +455,7 @@ function App({ selectedStderrLogState, ); - // Tools from ManagedToolsState (full list, auto-load on connect). Hook is only called when we have a manager (via ManagedToolsSync). + // Tools from ManagedToolsState (full list, auto-load on connect) const selectedManagedToolsState = useMemo( () => selectedServer && managedToolsStates[selectedServer] @@ -481,14 +463,7 @@ function App({ : null, [selectedServer, managedToolsStates], ); - const [managedToolsResult, setManagedToolsResult] = - useState({ tools: [], refresh: async () => [] }); - useEffect(() => { - if (!selectedManagedToolsState) { - setManagedToolsResult({ tools: [], refresh: async () => [] }); - } - }, [selectedManagedToolsState]); - const managedTools = managedToolsResult.tools; + const { tools: managedTools } = useManagedTools(selectedManagedToolsState); // Resources, resource templates, prompts from managed state managers const selectedManagedResourcesState = useMemo( @@ -1383,477 +1358,468 @@ function App({ }; return ( - <> - {selectedManagedToolsState ? ( - - ) : null} + + {/* Header row across the top */} - {/* Header row across the top */} + + + {packageJson.name} + + - {packageJson.description} + + v{packageJson.version} + + + {/* Main content area */} + + {/* Left column - Server list */} - - - {packageJson.name} + + + MCP Servers + + + + {serverNames.map((serverName) => { + const isSelected = selectedServer === serverName; + return ( + + + {isSelected ? "▶ " : " "} + {serverName} + + + ); + })} + + + {/* Fixed footer */} + + + ESC to exit - - {packageJson.description} - v{packageJson.version} - {/* Main content area */} + {/* Right column - Server details, Tabs and content */} - {/* Left column - Server list */} + {/* Server Details - Flexible height */} - - + - MCP Servers - - - - {serverNames.map((serverName) => { - const isSelected = selectedServer === serverName; - return ( - - - {isSelected ? "▶ " : " "} - {serverName} - - - ); - })} - - - {/* Fixed footer */} - - - ESC to exit - + + {selectedServer} + + + {currentServerState && ( + <> + + {getStatusSymbol(currentServerState.status)}{" "} + {currentServerState.status} + + + {(currentServerState?.status === "disconnected" || + currentServerState?.status === "error") && ( + + [Connect] + + )} + {(currentServerState?.status === "connected" || + currentServerState?.status === "connecting") && ( + + [Disconnect] + + )} + + )} + + + {show401AuthHint && ( + + + 401 Unauthorized. Press A to authenticate. + + + )} + {oauthStatus !== "idle" && ( + + {oauthStatus === "authenticating" && ( + OAuth: authenticating… + )} + {oauthStatus === "success" && oauthMessage && ( + {oauthMessage} + )} + {oauthStatus === "error" && oauthMessage && ( + OAuth: {oauthMessage} + )} + + )} - {/* Right column - Server details, Tabs and content */} + {/* Tabs */} + { + const serverType = + inspectorClients[selectedServer].getServerType(); + return ( + serverType === "sse" || serverType === "streamable-http" + ); + })() + : false + } + /> + + {/* Tab Content */} - {/* Server Details - Flexible height */} - - - - - {selectedServer} - - - {currentServerState && ( - <> - - {getStatusSymbol(currentServerState.status)}{" "} - {currentServerState.status} - - - {(currentServerState?.status === "disconnected" || - currentServerState?.status === "error") && ( - - [Connect] - - )} - {(currentServerState?.status === "connected" || - currentServerState?.status === "connecting") && ( - - [Disconnect] - - )} - - )} - - - {show401AuthHint && ( - - - 401 Unauthorized. Press A to - authenticate. - - - )} - {oauthStatus !== "idle" && ( - - {oauthStatus === "authenticating" && ( - OAuth: authenticating… - )} - {oauthStatus === "success" && oauthMessage && ( - {oauthMessage} - )} - {oauthStatus === "error" && oauthMessage && ( - OAuth: {oauthMessage} - )} - - )} - - - - {/* Tabs */} - { - const serverType = - inspectorClients[selectedServer].getServerType(); - return ( - serverType === "sse" || serverType === "streamable-http" - ); - })() - : false - } - /> - - {/* Tab Content */} - - {activeTab === "info" && ( - - )} - {activeTab === "auth" && - selectedServer && - selectedServerConfig && - isOAuthCapableServer(selectedServerConfig) ? ( - - ) : null} - {activeTab === "resources" && + {activeTab === "info" && ( + + )} + {activeTab === "auth" && + selectedServer && + selectedServerConfig && + isOAuthCapableServer(selectedServerConfig) ? ( + + ) : null} + {activeTab === "resources" && + currentServerState?.status === "connected" && + selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, resources: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onViewDetails={(resource) => + setDetailsModal({ + title: `Resource: ${"uri" in resource ? resource.name || resource.uri || "Unknown" : "Resource content"}`, + content: renderResourceDetails(resource), + }) + } + onFetchResource={() => { + // Resource fetching is handled internally by ResourcesTab + // This callback is just for triggering the fetch + }} + onFetchTemplate={(template) => { + setResourceTestModal({ + template, + inspectorClient: selectedInspectorClient, + }); + }} + modalOpen={ + !!(toolTestModal || resourceTestModal || detailsModal) + } + /> + ) : activeTab === "prompts" && currentServerState?.status === "connected" && selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, resources: count })) - } - focusedPane={ - focus === "tabContentDetails" - ? "details" - : focus === "tabContentList" - ? "list" - : null - } - onViewDetails={(resource) => - setDetailsModal({ - title: `Resource: ${"uri" in resource ? resource.name || resource.uri || "Unknown" : "Resource content"}`, - content: renderResourceDetails(resource), - }) - } - onFetchResource={() => { - // Resource fetching is handled internally by ResourcesTab - // This callback is just for triggering the fetch - }} - onFetchTemplate={(template) => { - setResourceTestModal({ - template, - inspectorClient: selectedInspectorClient, - }); - }} - modalOpen={ - !!(toolTestModal || resourceTestModal || detailsModal) - } - /> - ) : activeTab === "prompts" && - currentServerState?.status === "connected" && - selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, prompts: count })) - } - focusedPane={ - focus === "tabContentDetails" - ? "details" - : focus === "tabContentList" - ? "list" - : null - } - onViewDetails={(prompt) => - setDetailsModal({ - title: `Prompt: ${prompt.name || "Unknown"}`, - content: renderPromptDetails(prompt), - }) - } - onFetchPrompt={(prompt) => { - setPromptTestModal({ - prompt, - inspectorClient: selectedInspectorClient, - }); - }} - modalOpen={ - !!( - toolTestModal || - resourceTestModal || - promptTestModal || - detailsModal - ) - } - /> - ) : activeTab === "tools" && - currentServerState?.status === "connected" && - selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, tools: count })) - } - focusedPane={ - focus === "tabContentDetails" - ? "details" - : focus === "tabContentList" - ? "list" - : null - } - onTestTool={(tool) => - setToolTestModal({ - tool, - inspectorClient: selectedInspectorClient, - }) - } - onViewDetails={(tool) => - setDetailsModal({ - title: `Tool: ${tool.name || "Unknown"}`, - content: renderToolDetails(tool), - }) - } - modalOpen={!!(toolTestModal || detailsModal)} - /> - ) : activeTab === "messages" && selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, messages: count })) - } - focusedPane={ - focus === "messagesDetail" - ? "details" - : focus === "messagesList" - ? "messages" - : null - } - modalOpen={!!(toolTestModal || detailsModal)} - onViewDetails={(message) => { - const label = - message.direction === "request" && - "method" in message.message - ? message.message.method - : message.direction === "response" - ? "Response" - : message.direction === "notification" && - "method" in message.message - ? message.message.method - : "Message"; - setDetailsModal({ - title: `Message: ${label}`, - content: renderMessageDetails(message), - }); - }} - /> - ) : activeTab === "requests" && - selectedInspectorClient && - (inspectorStatus === "connected" || - inspectorFetchRequests.length > 0) ? ( - - setTabCounts((prev) => ({ ...prev, requests: count })) - } - focusedPane={ - focus === "requestsDetail" - ? "details" - : focus === "requestsList" - ? "requests" - : null - } - modalOpen={!!(toolTestModal || detailsModal)} - onViewDetails={(request) => { - setDetailsModal({ - title: `Request: ${request.method} ${request.url}`, - content: renderRequestDetails(request), - }); - }} - /> - ) : activeTab === "logging" && selectedInspectorClient ? ( - - setTabCounts((prev) => ({ ...prev, logging: count })) - } - focused={ - focus === "tabContentList" || focus === "tabContentDetails" - } - /> - ) : null} - + + setTabCounts((prev) => ({ ...prev, prompts: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onViewDetails={(prompt) => + setDetailsModal({ + title: `Prompt: ${prompt.name || "Unknown"}`, + content: renderPromptDetails(prompt), + }) + } + onFetchPrompt={(prompt) => { + setPromptTestModal({ + prompt, + inspectorClient: selectedInspectorClient, + }); + }} + modalOpen={ + !!( + toolTestModal || + resourceTestModal || + promptTestModal || + detailsModal + ) + } + /> + ) : activeTab === "tools" && + currentServerState?.status === "connected" && + selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, tools: count })) + } + focusedPane={ + focus === "tabContentDetails" + ? "details" + : focus === "tabContentList" + ? "list" + : null + } + onTestTool={(tool) => + setToolTestModal({ + tool, + inspectorClient: selectedInspectorClient, + }) + } + onViewDetails={(tool) => + setDetailsModal({ + title: `Tool: ${tool.name || "Unknown"}`, + content: renderToolDetails(tool), + }) + } + modalOpen={!!(toolTestModal || detailsModal)} + /> + ) : activeTab === "messages" && selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, messages: count })) + } + focusedPane={ + focus === "messagesDetail" + ? "details" + : focus === "messagesList" + ? "messages" + : null + } + modalOpen={!!(toolTestModal || detailsModal)} + onViewDetails={(message) => { + const label = + message.direction === "request" && + "method" in message.message + ? message.message.method + : message.direction === "response" + ? "Response" + : message.direction === "notification" && + "method" in message.message + ? message.message.method + : "Message"; + setDetailsModal({ + title: `Message: ${label}`, + content: renderMessageDetails(message), + }); + }} + /> + ) : activeTab === "requests" && + selectedInspectorClient && + (inspectorStatus === "connected" || + inspectorFetchRequests.length > 0) ? ( + + setTabCounts((prev) => ({ ...prev, requests: count })) + } + focusedPane={ + focus === "requestsDetail" + ? "details" + : focus === "requestsList" + ? "requests" + : null + } + modalOpen={!!(toolTestModal || detailsModal)} + onViewDetails={(request) => { + setDetailsModal({ + title: `Request: ${request.method} ${request.url}`, + content: renderRequestDetails(request), + }); + }} + /> + ) : activeTab === "logging" && selectedInspectorClient ? ( + + setTabCounts((prev) => ({ ...prev, logging: count })) + } + focused={ + focus === "tabContentList" || focus === "tabContentDetails" + } + /> + ) : null} + - {/* Tool Test Modal - rendered at App level for full screen overlay */} - {toolTestModal && ( - setToolTestModal(null)} - /> - )} + {/* Tool Test Modal - rendered at App level for full screen overlay */} + {toolTestModal && ( + setToolTestModal(null)} + /> + )} - {/* Resource Test Modal - rendered at App level for full screen overlay */} - {resourceTestModal && ( - setResourceTestModal(null)} - /> - )} + {/* Resource Test Modal - rendered at App level for full screen overlay */} + {resourceTestModal && ( + setResourceTestModal(null)} + /> + )} - {promptTestModal && ( - setPromptTestModal(null)} - /> - )} + {promptTestModal && ( + setPromptTestModal(null)} + /> + )} - {/* Details Modal - rendered at App level for full screen overlay */} - {detailsModal && ( - setDetailsModal(null)} - /> - )} - - + {/* Details Modal - rendered at App level for full screen overlay */} + {detailsModal && ( + setDetailsModal(null)} + /> + )} + ); } diff --git a/core/__tests__/react/useManagedTools.test.tsx b/core/__tests__/react/useManagedTools.test.tsx index 1c7cda4a6..a368e5fe3 100644 --- a/core/__tests__/react/useManagedTools.test.tsx +++ b/core/__tests__/react/useManagedTools.test.tsx @@ -46,6 +46,20 @@ class MockManagedToolsState { } describe("useManagedTools", () => { + it("returns empty tools and no-op refresh when manager is null or undefined", async () => { + const { result: resultNull } = renderHook(() => useManagedTools(null)); + expect(resultNull.current.tools).toEqual([]); + const fromNull = await resultNull.current.refresh(); + expect(fromNull).toEqual([]); + + const { result: resultUndef } = renderHook(() => + useManagedTools(undefined), + ); + expect(resultUndef.current.tools).toEqual([]); + const fromUndef = await resultUndef.current.refresh(); + expect(fromUndef).toEqual([]); + }); + it("syncs initial tools from manager store", () => { const manager = new MockManagedToolsState(); manager.setTools([ diff --git a/core/react/useManagedTools.ts b/core/react/useManagedTools.ts index 95e29b055..10a96002e 100644 --- a/core/react/useManagedTools.ts +++ b/core/react/useManagedTools.ts @@ -1,5 +1,4 @@ -import { useSyncExternalStore } from "react"; -import { useCallback } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { ManagedToolsState } from "../mcp/state/managedToolsState.js"; @@ -8,21 +7,24 @@ export interface UseManagedToolsResult { refresh: (metadata?: Record) => Promise; } +const EMPTY_TOOLS: Tool[] = []; // Stable empty array reference to avoid re-renders +const NOOP_SUBSCRIBE = () => () => {}; + /** * Subscribes to the manager's store and returns tools + refresh. - * Requires a ManagedToolsState (only call when you have a manager). + * When manager is null/undefined, returns empty tools and a no-op refresh. */ export function useManagedTools( - managedToolsState: ManagedToolsState, + managedToolsState: ManagedToolsState | null | undefined, ): UseManagedToolsResult { - const store = managedToolsState.getStore(); const tools = useSyncExternalStore( - store.subscribe, - () => store.getState().tools, + managedToolsState?.getStore()?.subscribe ?? NOOP_SUBSCRIBE, + () => managedToolsState?.getStore()?.getState()?.tools ?? EMPTY_TOOLS, ); const refresh = useCallback( async (metadata?: Record): Promise => { + if (!managedToolsState) return EMPTY_TOOLS; return managedToolsState.refresh(metadata); }, [managedToolsState], From d3ccb4a46a32f9bb54e42db6491917bb18374652 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 14 Mar 2026 22:28:04 -0700 Subject: [PATCH 3/3] Fix useSyncExternalStore by returning a stable store from ManagedToolsState.getStore() and resolving the store once in useManagedTools for subscribe and getSnapshot. --- core/mcp/state/managedToolsState.ts | 9 +++++---- core/react/useManagedTools.ts | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/core/mcp/state/managedToolsState.ts b/core/mcp/state/managedToolsState.ts index 9827c3223..6a8171b9e 100644 --- a/core/mcp/state/managedToolsState.ts +++ b/core/mcp/state/managedToolsState.ts @@ -24,6 +24,10 @@ export class ManagedToolsState { private readonly store = createStore<{ tools: Tool[] }>()((_set) => ({ tools: [], })); + private readonly readOnlyStore: ManagedToolsReadOnlyStore = { + getState: () => this.store.getState(), + subscribe: (listener) => this.store.subscribe(listener), + }; private client: InspectorClient | null = null; private unsubscribe: (() => void) | null = null; private _metadata: Record | undefined = undefined; @@ -56,10 +60,7 @@ export class ManagedToolsState { /** Read-only store for subscription (e.g. useStore(manager.getStore(), s => s.tools)). */ getStore(): ManagedToolsReadOnlyStore { - return { - getState: () => this.store.getState(), - subscribe: (listener) => this.store.subscribe(listener), - }; + return this.readOnlyStore; } getTools(): Tool[] { diff --git a/core/react/useManagedTools.ts b/core/react/useManagedTools.ts index 10a96002e..b85f6a077 100644 --- a/core/react/useManagedTools.ts +++ b/core/react/useManagedTools.ts @@ -17,9 +17,10 @@ const NOOP_SUBSCRIBE = () => () => {}; export function useManagedTools( managedToolsState: ManagedToolsState | null | undefined, ): UseManagedToolsResult { + const store = managedToolsState?.getStore() ?? null; const tools = useSyncExternalStore( - managedToolsState?.getStore()?.subscribe ?? NOOP_SUBSCRIBE, - () => managedToolsState?.getStore()?.getState()?.tools ?? EMPTY_TOOLS, + store?.subscribe ?? NOOP_SUBSCRIBE, + store ? () => store.getState().tools : () => EMPTY_TOOLS, ); const refresh = useCallback(