diff --git a/README.md b/README.md index 6239b18a..845f4d7d 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** `test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active. +`test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an *N pages loaded* status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. + ## Building ```bash diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index b44a34de..fc7fc9b1 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -187,12 +187,23 @@ const SERVER_A = { connection: { status: "disconnected" }, }; +// Stable spy so tests can assert the sidebar paginated toggle persisted the +// `paginatedLists` setting (#1721). `vi.hoisted` so it exists when the hoisted +// `vi.mock` factory closes over it. +const { updateServerSettingsSpy } = vi.hoisted(() => ({ + updateServerSettingsSpy: vi.fn(() => Promise.resolve()), +})); +// Stable spy for the tools list-changed acknowledgement, so a test can assert +// the paginated Refresh clears the indicator (#1721). +const { clearToolsListChangedSpy } = vi.hoisted(() => ({ + clearToolsListChangedSpy: vi.fn(), +})); vi.mock("@inspector/core/react/useServers.js", () => ({ useServers: vi.fn(() => ({ servers: [SERVER_A], addServer: vi.fn(), updateServer: vi.fn(), - updateServerSettings: vi.fn(), + updateServerSettings: updateServerSettingsSpy, removeServer: vi.fn(), })), })); @@ -211,13 +222,22 @@ vi.mock("@inspector/core/react/useManagedTools.js", () => ({ useManagedTools: vi.fn(() => ({ tools: [{ name: "get_acts", inputSchema: { type: "object" } }], refresh: vi.fn(), + clearListChanged: clearToolsListChangedSpy, })), })); vi.mock("@inspector/core/react/useManagedPrompts.js", () => ({ - useManagedPrompts: vi.fn(() => ({ prompts: [], refresh: vi.fn() })), + useManagedPrompts: vi.fn(() => ({ + prompts: [], + refresh: vi.fn(), + clearListChanged: vi.fn(), + })), })); vi.mock("@inspector/core/react/useManagedResources.js", () => ({ - useManagedResources: vi.fn(() => ({ resources: [], refresh: vi.fn() })), + useManagedResources: vi.fn(() => ({ + resources: [], + refresh: vi.fn(), + clearListChanged: vi.fn(), + })), })); vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({ useManagedResourceTemplates: vi.fn(() => ({ @@ -225,6 +245,58 @@ vi.mock("@inspector/core/react/useManagedResourceTemplates.js", () => ({ refresh: vi.fn(), })), })); +// Paged (paginated) hooks + state managers (#1721). Mirrors the managed +// mocks: the hooks return an empty accumulated list and a resolving loadPage so +// usePaginatedList runs without a real transport; the state classes are no-op +// constructors App still instantiates/destroys per connect. +vi.mock("@inspector/core/react/usePagedTools.js", () => ({ + usePagedTools: vi.fn(() => ({ + tools: [], + nextCursor: undefined, + pageCount: 0, + loadPage: vi.fn(() => + Promise.resolve({ tools: [], nextCursor: undefined }), + ), + clear: vi.fn(), + })), +})); +vi.mock("@inspector/core/react/usePagedPrompts.js", () => ({ + usePagedPrompts: vi.fn(() => ({ + prompts: [], + nextCursor: undefined, + pageCount: 0, + loadPage: vi.fn(() => + Promise.resolve({ prompts: [], nextCursor: undefined }), + ), + clear: vi.fn(), + })), +})); +vi.mock("@inspector/core/react/usePagedResources.js", () => ({ + usePagedResources: vi.fn(() => ({ + resources: [], + nextCursor: undefined, + pageCount: 0, + loadPage: vi.fn(() => + Promise.resolve({ resources: [], nextCursor: undefined }), + ), + clear: vi.fn(), + })), +})); +vi.mock("@inspector/core/mcp/state/pagedToolsState.js", () => ({ + PagedToolsState: vi.fn(function () { + return { destroy: vi.fn() }; + }), +})); +vi.mock("@inspector/core/mcp/state/pagedPromptsState.js", () => ({ + PagedPromptsState: vi.fn(function () { + return { destroy: vi.fn() }; + }), +})); +vi.mock("@inspector/core/mcp/state/pagedResourcesState.js", () => ({ + PagedResourcesState: vi.fn(function () { + return { destroy: vi.fn() }; + }), +})); vi.mock("@inspector/core/react/useManagedRequestorTasks.js", () => ({ useManagedRequestorTasks: vi.fn(() => ({ tasks: [], @@ -313,6 +385,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ onReplayProtocol: (id: string) => void; onTogglePinProtocol: (id: string) => void; pinnedProtocolIds?: Set; + onRefreshTools: () => void; + toolsPagination: { + paginated: boolean; + canLoadMore: boolean; + loadedPages: number; + onPaginatedChange: (v: boolean) => void; + onLoadMore: () => void; + }; }) => (
@@ -428,6 +508,22 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ replay-history + + {String(props.toolsPagination.paginated)} + + + {props.toolsPagination.loadedPages} + + + + +
), })); @@ -1889,5 +1985,100 @@ describe("App OAuth resume lifecycle", () => { INSPECTOR_SERVERS_TAB, ), ); + + await user.click(screen.getByText("connect")); + await waitFor(() => + expect(screen.getByTestId("active-tab")).toHaveTextContent( + INSPECTOR_SERVERS_TAB, + ), + ); + }); +}); + +describe("App paginated list pagination toggle (#1721)", () => { + beforeEach(() => { + clientInstances.length = 0; + updateServerSettingsSpy.mockClear(); + }); + + it("persists and live-pushes paginatedLists when the sidebar toggle flips", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false"); + + await user.click(screen.getByText("paginated-on")); + + // Optimistic UI flip is immediate. + await waitFor(() => + expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"), + ); + // Persisted to the server settings (survives reconnects). + expect(updateServerSettingsSpy).toHaveBeenCalledWith( + "A", + expect.objectContaining({ paginatedLists: true }), + ); + // Live-pushed to the client so the managed state's gating reads it now. + const client = clientInstances[0] as unknown as { + setServerSettings: ReturnType; + }; + expect(client.setServerSettings).toHaveBeenCalledWith( + expect.objectContaining({ paginatedLists: true }), + ); + + // Toggling back off persists false. + await user.click(screen.getByText("paginated-off")); + await waitFor(() => + expect(updateServerSettingsSpy).toHaveBeenCalledWith( + "A", + expect.objectContaining({ paginatedLists: false }), + ), + ); + }); + + it("routes Refresh and Load-next-page in paginated mode and clears the indicator", async () => { + const user = userEvent.setup(); + clearToolsListChangedSpy.mockClear(); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("paginated-on")); + await waitFor(() => + expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"), + ); + // Exercise the mode-aware Refresh (paginated → reload page 1) and the + // Load-next-page control; both should run without error. + await user.click(screen.getByText("refresh-tools")); + await user.click(screen.getByText("load-more-tools")); + expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"); + // The paginated Refresh must acknowledge the managed list-changed + // indicator (the paged reload bypasses the managed hook's refresh) (#1721). + expect(clearToolsListChangedSpy).toHaveBeenCalled(); + }); + + it("reverts the optimistic toggle when persisting the setting fails (#1721)", async () => { + const user = userEvent.setup(); + updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full")); + renderWithMantine(); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + + await user.click(screen.getByText("paginated-on")); + // The optimistic flip is rolled back once the persist rejects, so the UI + // reflects the (unchanged) persisted value rather than the failed edit. + await waitFor(() => + expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false"), + ); + // The live client setting was rolled back too (last call reverts it). + const client = clientInstances[0] as unknown as { + setServerSettings: ReturnType; + }; + const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as { + paginatedLists?: boolean; + }; + expect(lastPush?.paginatedLists).toBeFalsy(); }); }); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 0a11e7d0..d55945a8 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -52,6 +52,9 @@ import { import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js"; import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js"; import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js"; +import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState.js"; +import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState.js"; +import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState.js"; import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js"; import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js"; import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js"; @@ -91,6 +94,11 @@ import { getWebRemoteOAuthStorage } from "./lib/remoteOAuthStorage"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; +import { usePagedTools } from "@inspector/core/react/usePagedTools.js"; +import { usePagedPrompts } from "@inspector/core/react/usePagedPrompts.js"; +import { usePagedResources } from "@inspector/core/react/usePagedResources.js"; +import { usePaginatedList } from "./hooks/usePaginatedList"; +import type { ListPaginationControlsProps } from "./components/elements/ListPaginationControls/ListPaginationControls"; import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js"; import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js"; @@ -361,6 +369,7 @@ const EMPTY_SETTINGS: InspectorServerSettings = { requestTimeout: 0, taskTtl: DEFAULT_TASK_TTL_MS, autoRefreshOnListChanged: false, + paginatedLists: false, maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, roots: [], }; @@ -745,6 +754,14 @@ function App() { useState(null); const [managedResourcesState, setManagedResourcesState] = useState(null); + // Paged (paginated) counterparts, used when the `paginatedLists` setting + // is on (#1721). Created/destroyed alongside the managed states. + const [pagedToolsState, setPagedToolsState] = + useState(null); + const [pagedPromptsState, setPagedPromptsState] = + useState(null); + const [pagedResourcesState, setPagedResourcesState] = + useState(null); const [managedResourceTemplatesState, setManagedResourceTemplatesState] = useState(null); const [managedRequestorTasksState, setManagedRequestorTasksState] = @@ -912,22 +929,93 @@ function App() { lastError, } = useInspectorClient(inspectorClient); const { - tools, + tools: managedTools, listChanged: toolsListChanged, refresh: refreshTools, + clearListChanged: clearToolsListChanged, } = useManagedTools(inspectorClient, managedToolsState); const { - prompts, + prompts: managedPrompts, listChanged: promptsListChanged, refresh: refreshPrompts, + clearListChanged: clearPromptsListChanged, } = useManagedPrompts(inspectorClient, managedPromptsState); const { - resources, + resources: managedResources, listChanged: resourcesListChanged, refresh: refreshResources, + clearListChanged: clearResourcesListChanged, } = useManagedResources(inspectorClient, managedResourcesState); const { resourceTemplates, refresh: refreshResourceTemplates } = useManagedResourceTemplates(inspectorClient, managedResourceTemplatesState); + // Paged (paginated) list sources. When `paginatedLists` is on the managed + // states skip their all-page walk and these drive the sidebar instead (#1721). + const { + tools: pagedTools, + nextCursor: pagedToolsCursor, + pageCount: pagedToolsPageCount, + loadPage: loadToolsPage, + } = usePagedTools(inspectorClient, pagedToolsState); + const { + prompts: pagedPrompts, + nextCursor: pagedPromptsCursor, + pageCount: pagedPromptsPageCount, + loadPage: loadPromptsPage, + } = usePagedPrompts(inspectorClient, pagedPromptsState); + const { + resources: pagedResources, + nextCursor: pagedResourcesCursor, + pageCount: pagedResourcesPageCount, + loadPage: loadResourcesPage, + } = usePagedResources(inspectorClient, pagedResourcesState); + // The active server's persisted paginated setting drives the display mode. + // The sidebar toggle edits it (optimistically, below) and persists it. + const persistedPaginatedLists = + servers.find((s) => s.id === activeServerId)?.settings?.paginatedLists ?? + false; + const [paginatedListsOverride, setPaginatedListsOverride] = useState< + boolean | null + >(null); + // Drop the optimistic override once the persisted value catches up (or the + // active server changes), so the persisted setting is the resting source. + useEffect(() => { + setPaginatedListsOverride(null); + }, [persistedPaginatedLists, activeServerId]); + const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists; + const connected = connectionStatus === "connected"; + const toolsPagination = usePaginatedList({ + connected, + paginated: paginatedLists, + managedItems: managedTools, + managedRefresh: refreshTools, + pagedItems: pagedTools, + pagedNextCursor: pagedToolsCursor, + pagedPageCount: pagedToolsPageCount, + loadPage: loadToolsPage, + }); + const promptsPagination = usePaginatedList({ + connected, + paginated: paginatedLists, + managedItems: managedPrompts, + managedRefresh: refreshPrompts, + pagedItems: pagedPrompts, + pagedNextCursor: pagedPromptsCursor, + pagedPageCount: pagedPromptsPageCount, + loadPage: loadPromptsPage, + }); + const resourcesPagination = usePaginatedList({ + connected, + paginated: paginatedLists, + managedItems: managedResources, + managedRefresh: refreshResources, + pagedItems: pagedResources, + pagedNextCursor: pagedResourcesCursor, + pagedPageCount: pagedResourcesPageCount, + loadPage: loadResourcesPage, + }); + const tools = toolsPagination.items; + const prompts = promptsPagination.items; + const resources = resourcesPagination.items; const { tasks, refresh: refreshTasks, @@ -2067,6 +2155,9 @@ function App() { managedToolsState?.destroy(); managedPromptsState?.destroy(); managedResourcesState?.destroy(); + pagedToolsState?.destroy(); + pagedPromptsState?.destroy(); + pagedResourcesState?.destroy(); managedResourceTemplatesState?.destroy(); managedRequestorTasksState?.destroy(); resourceSubscriptionsState?.destroy(); @@ -2166,6 +2257,9 @@ function App() { setInspectorClient(client); setManagedToolsState(new ManagedToolsState(client)); + setPagedToolsState(new PagedToolsState(client)); + setPagedPromptsState(new PagedPromptsState(client)); + setPagedResourcesState(new PagedResourcesState(client)); setManagedPromptsState(new ManagedPromptsState(client)); const nextResourcesState = new ManagedResourcesState(client); setManagedResourcesState(nextResourcesState); @@ -2204,6 +2298,9 @@ function App() { managedToolsState, managedPromptsState, managedResourcesState, + pagedToolsState, + pagedPromptsState, + pagedResourcesState, managedResourceTemplatesState, managedRequestorTasksState, resourceSubscriptionsState, @@ -3146,18 +3243,189 @@ function App() { [inspectorClient, runWithCommandAuthRecovery], ); + // Refresh acts per pagination mode: in paginated mode reload page 1 (the + // paged state); in all-pages mode re-fetch the whole aggregate with auth + // recovery (the pre-existing path). See usePaginatedList / #1721. const onRefreshTools = useCallback(() => { - void runWithCommandAuthRecovery(() => refreshTools(), "ambient"); - }, [refreshTools, runWithCommandAuthRecovery]); + if (paginatedLists) { + // Paginated refresh reloads page 1 of the paged store, bypassing the + // managed hook's refresh — so acknowledge the list-changed indicator here + // (the managed state lit it on `list_changed`; nothing else clears it in + // paginated mode) (#1721). + clearToolsListChanged(); + void runWithCommandAuthRecovery( + () => toolsPagination.onRefresh(), + "ambient", + ); + } else { + void runWithCommandAuthRecovery(() => refreshTools(), "ambient"); + } + }, [ + paginatedLists, + toolsPagination, + refreshTools, + clearToolsListChanged, + runWithCommandAuthRecovery, + ]); const onRefreshPrompts = useCallback(() => { - void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient"); - }, [refreshPrompts, runWithCommandAuthRecovery]); + if (paginatedLists) { + clearPromptsListChanged(); + void runWithCommandAuthRecovery( + () => promptsPagination.onRefresh(), + "ambient", + ); + } else { + void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient"); + } + }, [ + paginatedLists, + promptsPagination, + refreshPrompts, + clearPromptsListChanged, + runWithCommandAuthRecovery, + ]); const onRefreshResources = useCallback(() => { - void runWithCommandAuthRecovery(async () => { - await refreshResources(); - await refreshResourceTemplates(); - }, "ambient"); - }, [refreshResources, refreshResourceTemplates, runWithCommandAuthRecovery]); + if (paginatedLists) { + clearResourcesListChanged(); + void runWithCommandAuthRecovery( + () => resourcesPagination.onRefresh(), + "ambient", + ); + // Resource templates always use the managed (aggregate) path. + void runWithCommandAuthRecovery( + () => refreshResourceTemplates(), + "ambient", + ); + } else { + void runWithCommandAuthRecovery(async () => { + await refreshResources(); + await refreshResourceTemplates(); + }, "ambient"); + } + }, [ + paginatedLists, + resourcesPagination, + refreshResources, + refreshResourceTemplates, + clearResourcesListChanged, + runWithCommandAuthRecovery, + ]); + // The per-list sidebar toggle edits the server-wide `paginatedLists` setting: + // optimistic override for an instant flip, live push so the managed state's + // gating reads it now, and a persisted PUT so it survives reconnects (#1721). + const onTogglePaginatedLists = useCallback( + (value: boolean) => { + setPaginatedListsOverride(value); + const current = servers.find((s) => s.id === activeServerId); + if (!current || activeServerId === undefined) return; + const prevSettings = current.settings ?? EMPTY_SETTINGS; + const next: InspectorServerSettings = { + ...prevSettings, + paginatedLists: value, + }; + inspectorClient?.setServerSettings(next); + // Drive the load that the mode change implies (data-loading stays out of + // React effects; the paged stores own only the connect-time load). To + // paginated: pull page 1 into each paged store. To all-pages: refetch + // each managed aggregate that was gated off. Only when connected. + if (connected) { + // Wrap in ambient auth recovery so a mid-session 401 triggers re-auth + // rather than surfacing raw, matching the all-pages refresh path. + if (value) { + void runWithCommandAuthRecovery( + () => loadToolsPage(undefined), + "ambient", + ); + void runWithCommandAuthRecovery( + () => loadPromptsPage(undefined), + "ambient", + ); + void runWithCommandAuthRecovery( + () => loadResourcesPage(undefined), + "ambient", + ); + } else { + void runWithCommandAuthRecovery(() => refreshTools(), "ambient"); + void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient"); + void runWithCommandAuthRecovery(() => refreshResources(), "ambient"); + } + } + void updateServerSettings(activeServerId, next).catch((err: unknown) => { + // Persist failed: revert the optimistic override (the effect only + // clears it when the persisted value changes, which won't happen here) + // and roll the live client setting back, so the UI and client reflect + // the value that's actually on disk rather than the failed edit (#1721). + setPaginatedListsOverride(null); + inspectorClient?.setServerSettings(prevSettings); + notifications.show({ + title: "Failed to save pagination setting", + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + }); + }, + [ + servers, + activeServerId, + inspectorClient, + updateServerSettings, + connected, + loadToolsPage, + loadPromptsPage, + loadResourcesPage, + refreshTools, + refreshPrompts, + refreshResources, + runWithCommandAuthRecovery, + ], + ); + // Wrap Load-next-page in ambient auth recovery too, so a paginated + // paginated fetch that hits a 401 recovers like the all-pages path (#1721). + const onLoadMoreTools = useCallback( + () => + void runWithCommandAuthRecovery( + () => toolsPagination.onLoadMore(), + "ambient", + ), + [toolsPagination, runWithCommandAuthRecovery], + ); + const onLoadMorePrompts = useCallback( + () => + void runWithCommandAuthRecovery( + () => promptsPagination.onLoadMore(), + "ambient", + ), + [promptsPagination, runWithCommandAuthRecovery], + ); + const onLoadMoreResources = useCallback( + () => + void runWithCommandAuthRecovery( + () => resourcesPagination.onLoadMore(), + "ambient", + ), + [resourcesPagination, runWithCommandAuthRecovery], + ); + const toolsPaginationControls: ListPaginationControlsProps = { + paginated: toolsPagination.paginated, + onPaginatedChange: onTogglePaginatedLists, + canLoadMore: toolsPagination.canLoadMore, + loadedPages: toolsPagination.loadedPages, + onLoadMore: onLoadMoreTools, + }; + const promptsPaginationControls: ListPaginationControlsProps = { + paginated: promptsPagination.paginated, + onPaginatedChange: onTogglePaginatedLists, + canLoadMore: promptsPagination.canLoadMore, + loadedPages: promptsPagination.loadedPages, + onLoadMore: onLoadMorePrompts, + }; + const resourcesPaginationControls: ListPaginationControlsProps = { + paginated: resourcesPagination.paginated, + onPaginatedChange: onTogglePaginatedLists, + canLoadMore: resourcesPagination.canLoadMore, + loadedPages: resourcesPagination.loadedPages, + onLoadMore: onLoadMoreResources, + }; const onRefreshTasks = useCallback(() => { void runWithCommandAuthRecovery(() => refreshTasks(), "ambient")?.catch( (err: unknown) => { @@ -3339,6 +3607,9 @@ function App() { managedToolsState?.destroy(); managedPromptsState?.destroy(); managedResourcesState?.destroy(); + pagedToolsState?.destroy(); + pagedPromptsState?.destroy(); + pagedResourcesState?.destroy(); managedResourceTemplatesState?.destroy(); managedRequestorTasksState?.destroy(); resourceSubscriptionsState?.destroy(); @@ -3349,6 +3620,9 @@ function App() { setManagedToolsState(null); setManagedPromptsState(null); setManagedResourcesState(null); + setPagedToolsState(null); + setPagedPromptsState(null); + setPagedResourcesState(null); setManagedResourceTemplatesState(null); setManagedRequestorTasksState(null); setResourceSubscriptionsState(null); @@ -3366,6 +3640,9 @@ function App() { managedToolsState, managedPromptsState, managedResourcesState, + pagedToolsState, + pagedPromptsState, + pagedResourcesState, managedResourceTemplatesState, managedRequestorTasksState, resourceSubscriptionsState, @@ -3962,6 +4239,9 @@ function App() { onClearToolResult={onClearToolResult} onReadResourceContents={onReadResourceContents} onRefreshTools={onRefreshTools} + toolsPagination={toolsPaginationControls} + promptsPagination={promptsPaginationControls} + resourcesPagination={resourcesPaginationControls} onPromptsUiChange={setPromptsUi} onGetPrompt={(name, args) => { void onGetPrompt(name, args); diff --git a/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.stories.tsx b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.stories.tsx new file mode 100644 index 00000000..1b23f841 --- /dev/null +++ b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { ListPaginationControls } from "./ListPaginationControls"; + +const meta: Meta = { + title: "Elements/ListPaginationControls", + component: ListPaginationControls, + args: { + onPaginatedChange: fn(), + onLoadMore: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllPages: Story = { + args: { + paginated: false, + canLoadMore: false, + loadedPages: 0, + }, +}; + +export const SinglePageWithMore: Story = { + args: { + paginated: true, + canLoadMore: true, + loadedPages: 2, + }, +}; + +export const SinglePageAtEnd: Story = { + args: { + paginated: true, + canLoadMore: false, + loadedPages: 3, + }, +}; + +export const SinglePageFirstPage: Story = { + args: { + paginated: true, + canLoadMore: true, + loadedPages: 1, + }, +}; diff --git a/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.test.tsx b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.test.tsx new file mode 100644 index 00000000..ee24ac8d --- /dev/null +++ b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import userEvent from "@testing-library/user-event"; +import { ListPaginationControls } from "./ListPaginationControls"; + +const baseProps = { + paginated: false, + onPaginatedChange: () => {}, + canLoadMore: false, + loadedPages: 0, + onLoadMore: () => {}, +}; + +describe("ListPaginationControls", () => { + it("renders only the 'Paginated' switch in all-pages mode (no load-more)", () => { + renderWithMantine(); + const toggle = screen.getByRole("switch"); + expect(toggle).not.toBeChecked(); + expect(screen.getByText("Paginated")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Load next page" }), + ).not.toBeInTheDocument(); + }); + + it("toggles paginated mode via the switch", async () => { + const user = userEvent.setup(); + const onPaginatedChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("switch")); + expect(onPaginatedChange).toHaveBeenCalledWith(true); + }); + + it("shows the load-more button and page count in paginated mode", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("switch")).toBeChecked(); + expect( + screen.getByRole("button", { name: "Load next page" }), + ).toBeEnabled(); + expect(screen.getByText("2 pages loaded")).toBeInTheDocument(); + }); + + it("uses the singular label for one page (while more remain)", () => { + renderWithMantine( + , + ); + expect(screen.getByText("1 page loaded")).toBeInTheDocument(); + }); + + it("hides the whole control when the list is a single page", () => { + // Paginated mode, page 1 loaded, no next cursor → nothing to paginate. + renderWithMantine( + , + ); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Load next page" }), + ).not.toBeInTheDocument(); + }); + + it("still shows the switch while a paginated load is pending (0 pages)", () => { + // loadedPages 0 (page 1 not yet loaded) must not trip the paginated hide. + renderWithMantine( + , + ); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("marks the end when there are multiple pages and no more to load", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Load next page" }), + ).toBeDisabled(); + expect(screen.getByText("3 pages loaded · end")).toBeInTheDocument(); + }); + + it("invokes onLoadMore when the button is clicked", async () => { + const user = userEvent.setup(); + const onLoadMore = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Load next page" })); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx new file mode 100644 index 00000000..3ef19211 --- /dev/null +++ b/clients/web/src/components/elements/ListPaginationControls/ListPaginationControls.tsx @@ -0,0 +1,86 @@ +import { Button, Group, Stack, Switch, Text } from "@mantine/core"; + +export interface ListPaginationControlsProps { + /** + * True when the list is fetched one page at a time (backed by the server's + * `paginatedLists` setting). False = auto-aggregate every page on load. + */ + paginated: boolean; + /** Toggle paginated mode. Wired to write the `paginatedLists` setting. */ + onPaginatedChange: (paginated: boolean) => void; + /** Paginated mode only: the server returned a `nextCursor` to load. */ + canLoadMore: boolean; + /** Paginated mode only: number of pages loaded so far (status label). */ + loadedPages: number; + /** Paginated mode only: fetch the next page. */ + onLoadMore: () => void; +} + +// Switch fully left, "Load next page" fully right; the page-count sits on its +// own line under the row. +const ControlsRow = Group.withProps({ + gap: "sm", + align: "center", + justify: "space-between", + wrap: "nowrap", +}); + +const ModeSwitch = Switch.withProps({ + size: "sm", + "aria-label": "Fetch lists one page at a time", +}); + +const LoadMoreButton = Button.withProps({ + size: "compact-sm", + variant: "light", +}); + +const StatusText = Text.withProps({ + size: "xs", + ta: "center", + c: "var(--inspector-text-secondary)", +}); + +/** + * Sidebar control for a paginated list (Tools/Resources/Prompts). A "Paginated" + * switch toggles between auto-aggregating every page and fetching one page at a + * time; in paginated mode a "Load next page" button (to the right of the + * switch) surfaces the server's `nextCursor` and a status shows how many pages + * are loaded. Hidden entirely once the list is known to be a single page, since + * there's nothing to paginate. + */ +export function ListPaginationControls({ + paginated, + onPaginatedChange, + canLoadMore, + loadedPages, + onLoadMore, +}: ListPaginationControlsProps) { + // The list turned out to be a single page (loaded page 1, no `nextCursor`): + // pagination is moot, so hide the whole control rather than show a useless + // toggle + disabled button (#1721). + if (paginated && !canLoadMore && loadedPages === 1) return null; + + return ( + + + onPaginatedChange(e.currentTarget.checked)} + /> + {paginated ? ( + + Load next page + + ) : null} + + {paginated ? ( + + {loadedPages} {loadedPages === 1 ? "page" : "pages"} loaded + {canLoadMore ? "" : " · end"} + + ) : null} + + ); +} diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.stories.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.stories.tsx index 89ec8d3c..9d0d3a38 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.stories.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { noopPagination } from "../../../test/fixtures/pagination"; import type { Prompt } from "@modelcontextprotocol/client"; import { fn } from "storybook/test"; import { PromptControls } from "./PromptControls"; @@ -7,6 +8,7 @@ const meta: Meta = { title: "Groups/PromptControls", component: PromptControls, args: { + pagination: noopPagination, onRefreshList: fn(), onSelectPrompt: fn(), onSearchChange: fn(), diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx index b01ccd62..570fe040 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import type { Prompt } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { PromptControls, type PromptControlsProps } from "./PromptControls"; +import { noopPagination } from "../../../test/fixtures/pagination"; const samplePrompts: Prompt[] = [ { @@ -25,6 +26,7 @@ const baseProps = { onRefreshList: vi.fn(), onSelectPrompt: vi.fn(), onSearchChange: vi.fn(), + pagination: noopPagination, }; // The search box is controlled: typing fires onSearchChange but does not diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index 5a8f847f..3f0544f8 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -2,6 +2,10 @@ import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Prompt } from "@modelcontextprotocol/client"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { + ListPaginationControls, + type ListPaginationControlsProps, +} from "../../elements/ListPaginationControls/ListPaginationControls"; import { PromptListItem } from "../PromptListItem/PromptListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; @@ -13,6 +17,8 @@ export interface PromptControlsProps { searchText?: string; listChanged: boolean; onRefreshList: () => void; + /** Pagination controls (#1721). */ + pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; onSelectPrompt: (name: string) => void; } @@ -23,6 +29,7 @@ export function PromptControls({ searchText = "", listChanged, onRefreshList, + pagination, onSearchChange, onSelectPrompt, }: PromptControlsProps) { @@ -53,6 +60,7 @@ export function PromptControls({ searchText ? onSearchChange("")} /> : null } /> + {filteredPrompts.map((prompt) => ( diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx index f15d2364..958ec6aa 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { noopPagination } from "../../../test/fixtures/pagination"; import type { Resource, ResourceTemplateType as ResourceTemplate, @@ -26,6 +27,7 @@ const meta: Meta = { title: "Groups/ResourceControls", component: ResourceControls, args: { + pagination: noopPagination, onRefreshList: fn(), onSelectUri: fn(), onSelectTemplate: fn(), diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx index 0ee67c46..df72686b 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx @@ -11,6 +11,7 @@ import { ResourceControls, type ResourceControlsProps, } from "./ResourceControls"; +import { noopPagination } from "../../../test/fixtures/pagination"; const sampleResources: Resource[] = [ { name: "config.json", uri: "file:///config.json" }, @@ -41,6 +42,7 @@ const baseProps = { onUnsubscribeResource: vi.fn(), compact: false, onCompactChange: vi.fn(), + pagination: noopPagination, }; // ResourceControls is controlled: search text + accordion open-sections live in diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 31239774..fea2e518 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -7,6 +7,10 @@ import type { } from "@modelcontextprotocol/client"; import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { + ListPaginationControls, + type ListPaginationControlsProps, +} from "../../elements/ListPaginationControls/ListPaginationControls"; import { ListToggle } from "../../elements/ListToggle/ListToggle"; import { ResourceListItem } from "../ResourceListItem/ResourceListItem"; import { ResourceSubscribedItem } from "../ResourceSubscribedItem/ResourceSubscribedItem"; @@ -32,6 +36,8 @@ export interface ResourceControlsProps { openSections?: string[]; listChanged: boolean; onRefreshList: () => void; + /** Pagination controls for the Resources list (#1721). */ + pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; onOpenSectionsChange: (value: string[]) => void; onSelectUri: (uri: string) => void; @@ -71,6 +77,7 @@ export function ResourceControls({ openSections: controlledOpenSections, listChanged, onRefreshList, + pagination, onSearchChange, onOpenSectionsChange, onSelectUri, @@ -186,6 +193,7 @@ export function ResourceControls({ /> + { + args.onPaginatedListsChange(value); + updateArgs({ + settings: { ...args.settings, paginatedLists: value }, + }); + }} onMaxFetchRequestsChange={(value) => { args.onMaxFetchRequestsChange(value); updateArgs({ @@ -178,6 +184,7 @@ const meta: Meta = { onMetadataChange: fn(), onTimeoutChange: fn(), onAutoRefreshChange: fn(), + onPaginatedListsChange: fn(), onMaxFetchRequestsChange: fn(), onProtocolEraChange: fn(), onOAuthChange: fn(), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 553efc3a..7d76ec0d 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -65,6 +65,7 @@ const baseHandlers = { onMetadataChange: vi.fn(), onTimeoutChange: vi.fn(), onAutoRefreshChange: vi.fn(), + onPaginatedListsChange: vi.fn(), onMaxFetchRequestsChange: vi.fn(), onProtocolEraChange: vi.fn(), onOAuthChange: vi.fn(), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 98fa9e83..c7fc6e29 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -58,6 +58,7 @@ export interface ServerSettingsFormProps { value: number, ) => void; onAutoRefreshChange: (value: boolean) => void; + onPaginatedListsChange: (value: boolean) => void; onMaxFetchRequestsChange: (value: number) => void; onProtocolEraChange: (value: ServerProtocolEra) => void; onOAuthChange: (oauth: OAuthSettings) => void; @@ -239,6 +240,7 @@ export function ServerSettingsForm({ onMetadataChange, onTimeoutChange, onAutoRefreshChange, + onPaginatedListsChange, onMaxFetchRequestsChange, onProtocolEraChange, onOAuthChange, @@ -314,6 +316,12 @@ export function ServerSettingsForm({ checked={settings.autoRefreshOnListChanged ?? false} onChange={(e) => onAutoRefreshChange(e.currentTarget.checked)} /> + onPaginatedListsChange(e.currentTarget.checked)} + /> = { title: "Groups/ToolControls", component: ToolControls, args: { + pagination: noopPagination, onRefreshList: fn(), onSelectTool: fn(), onSearchChange: fn(), diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index a5c98ee9..1e0290aa 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import type { Tool } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { ToolControls, type ToolControlsProps } from "./ToolControls"; +import { noopPagination } from "../../../test/fixtures/pagination"; const sampleTools: Tool[] = [ { name: "list_files", title: "List Files", inputSchema: { type: "object" } }, @@ -22,6 +23,7 @@ const baseProps = { onRefreshList: vi.fn(), onSelectTool: vi.fn(), onSearchChange: vi.fn(), + pagination: noopPagination, }; // The search box is controlled: typing fires onSearchChange but does not diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 1065fd7c..dd7ca5c8 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -2,6 +2,10 @@ import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Tool } from "@modelcontextprotocol/client"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { + ListPaginationControls, + type ListPaginationControlsProps, +} from "../../elements/ListPaginationControls/ListPaginationControls"; import { ToolListItem } from "../ToolListItem/ToolListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; @@ -13,6 +17,8 @@ export interface ToolControlsProps { searchText?: string; listChanged: boolean; onRefreshList: () => void; + /** Pagination controls (#1721). */ + pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; onSelectTool: (name: string) => void; } @@ -23,6 +29,7 @@ export function ToolControls({ searchText = "", listChanged, onRefreshList, + pagination, onSearchChange, onSelectTool, }: ToolControlsProps) { @@ -61,6 +68,7 @@ export function ToolControls({ searchText ? onSearchChange("")} /> : null } /> + {filteredTools.map((tool) => ( diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx index 0558b563..8728cc73 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { noopPagination } from "../../../test/fixtures/pagination"; import type { ComponentProps } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Prompt } from "@modelcontextprotocol/client"; @@ -21,6 +22,7 @@ const meta: Meta = { component: PromptsScreen, parameters: { layout: "fullscreen" }, args: { + pagination: noopPagination, ui: EMPTY_PROMPTS_UI, onUiChange: fn(), onRefreshList: fn(), diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx index 1fc96691..bbb96910 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { Prompt } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { noopPagination } from "../../../test/fixtures/pagination"; import { PromptsScreen, type PromptsScreenProps, @@ -34,6 +35,7 @@ const baseProps = { ui: EMPTY_PROMPTS_UI, onUiChange: vi.fn(), onRefreshList: vi.fn(), + pagination: noopPagination, onGetPrompt: vi.fn(), }; diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index a81f2199..94e2453c 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -10,6 +10,7 @@ import { } from "@mantine/core"; import type { GetPromptResult, Prompt } from "@modelcontextprotocol/client"; import { PromptControls } from "../../groups/PromptControls/PromptControls"; +import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; import { PromptArgumentsForm } from "../../groups/PromptArgumentsForm/PromptArgumentsForm"; import { PromptMessagesDisplay } from "../../groups/PromptMessagesDisplay/PromptMessagesDisplay"; @@ -32,6 +33,8 @@ export interface PromptsScreenProps { completionsSupported?: boolean; onUiChange: (next: PromptsUiState) => void; onRefreshList: () => void; + /** Pagination controls rendered in the sidebar (#1721). */ + pagination: ListPaginationControlsProps; onGetPrompt: (name: string, args: Record) => void; onCopyMessages?: () => void; onCompleteArgument?: ( @@ -62,7 +65,9 @@ const ScreenLayout = Flex.withProps({ }); const Sidebar = Stack.withProps({ - w: 340, + // Widened from 340 to comfortably fit the pagination controls + // (Load-next-page button + status) without cramping list entries (#1721). + w: 360, flex: "0 0 auto", }); @@ -119,6 +124,7 @@ export function PromptsScreen({ completionsSupported, onUiChange, onRefreshList, + pagination, onGetPrompt, onCopyMessages, onCompleteArgument, @@ -255,6 +261,7 @@ export function PromptsScreen({ searchText={search} listChanged={listChanged} onRefreshList={onRefreshList} + pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectPrompt={handleSelectPrompt} /> diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index 3dd72ad6..aee4edbf 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { noopPagination } from "../../../test/fixtures/pagination"; import type { ComponentProps } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { @@ -26,6 +27,7 @@ const meta: Meta = { component: ResourcesScreen, parameters: { layout: "fullscreen" }, args: { + pagination: noopPagination, ui: EMPTY_RESOURCES_UI, onUiChange: fn(), onRefreshList: fn(), diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx index 40d886f3..47c3f7e4 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx @@ -7,6 +7,7 @@ import type { ReadResourceResult, } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { noopPagination } from "../../../test/fixtures/pagination"; import { ResourcesScreen, type ResourcesScreenProps, @@ -31,6 +32,7 @@ const baseProps = { ui: EMPTY_RESOURCES_UI, onUiChange: vi.fn(), onRefreshList: vi.fn(), + pagination: noopPagination, onReadResource: vi.fn(), onSubscribeResource: vi.fn(), onUnsubscribeResource: vi.fn(), diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index dd9d9a72..95ddd220 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -15,6 +15,7 @@ import type { } from "@modelcontextprotocol/client"; import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js"; import { ResourceControls } from "../../groups/ResourceControls/ResourceControls"; +import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ResourcePreviewPanel } from "../../groups/ResourcePreviewPanel/ResourcePreviewPanel"; import { ResourceTemplatePanel } from "../../groups/ResourceTemplatePanel/ResourceTemplatePanel"; @@ -44,6 +45,8 @@ export interface ResourcesScreenProps { subscriptionsSupported?: boolean; onUiChange: (next: ResourcesUiState) => void; onRefreshList: () => void; + /** Pagination controls rendered in the sidebar (#1721). */ + pagination: ListPaginationControlsProps; onReadResource: (uri: string) => void; onSubscribeResource: (uri: string) => void; onUnsubscribeResource: (uri: string) => void; @@ -80,7 +83,9 @@ const ScreenLayout = Flex.withProps({ }); const Sidebar = Stack.withProps({ - w: 340, + // Widened from 340 to comfortably fit the pagination controls + // (Load-next-page button + status) without cramping list entries (#1721). + w: 360, flex: "0 0 auto", }); @@ -139,6 +144,7 @@ export function ResourcesScreen({ subscriptionsSupported = true, onUiChange, onRefreshList, + pagination, onReadResource, onSubscribeResource, onUnsubscribeResource, @@ -291,6 +297,7 @@ export function ResourcesScreen({ openSections={openSections} listChanged={listChanged} onRefreshList={onRefreshList} + pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onOpenSectionsChange={(value) => onUiChange({ ...ui, openSections: value }) diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx index ae6be575..11848bf8 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { noopPagination } from "../../../test/fixtures/pagination"; import type { ComponentProps } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ElicitRequest, Tool } from "@modelcontextprotocol/client"; @@ -27,6 +28,7 @@ const meta: Meta = { component: ToolsScreen, parameters: { layout: "fullscreen" }, args: { + pagination: noopPagination, listChanged: false, serverSupportsTaskToolCalls: false, ui: EMPTY_TOOLS_UI, diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index 66f579d3..6643d949 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { Tool } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { noopPagination } from "../../../test/fixtures/pagination"; import { ToolsScreen, type ToolsScreenProps, @@ -34,6 +35,7 @@ const baseProps = { ui: EMPTY_TOOLS_UI, onUiChange: vi.fn(), onRefreshList: vi.fn(), + pagination: noopPagination, onCallTool: vi.fn(), }; diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index 787c12c5..ae50d6e9 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -5,6 +5,7 @@ import type { Tool, } from "@modelcontextprotocol/client"; import { ToolControls } from "../../groups/ToolControls/ToolControls"; +import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ToolDetailPanel, type ToolProgress, @@ -44,6 +45,8 @@ export interface ToolsScreenProps { serverSupportsTaskToolCalls: boolean; onUiChange: (next: ToolsUiState) => void; onRefreshList: () => void; + /** Pagination controls rendered in the sidebar (#1721). */ + pagination: ListPaginationControlsProps; onCallTool: ( name: string, args: Record, @@ -76,7 +79,9 @@ const ScreenLayout = Flex.withProps({ }); const Sidebar = Stack.withProps({ - w: 340, + // Widened from 340 to comfortably fit the pagination controls + // (Load-next-page button + status) without cramping list entries (#1721). + w: 360, flex: "0 0 auto", }); @@ -130,6 +135,7 @@ export function ToolsScreen({ serverSupportsTaskToolCalls, onUiChange, onRefreshList, + pagination, onCallTool, onCancelCall, onClearResult, @@ -151,6 +157,7 @@ export function ToolsScreen({ searchText={search} listChanged={listChanged} onRefreshList={onRefreshList} + pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectTool={(name) => { // Seed the form with the tool's schema defaults so default-only diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 3af88729..7dc70052 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -18,6 +18,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; import { expect, fn } from "storybook/test"; import { InspectorView } from "./InspectorView"; +import { noopPagination } from "../../../test/fixtures/pagination"; import { EMPTY_TOOLS_UI, EMPTY_APPS_UI, @@ -385,6 +386,9 @@ const meta: Meta = { onToolsUiChange: fn(), onCallTool: fn(), onRefreshTools: fn(), + toolsPagination: noopPagination, + promptsPagination: noopPagination, + resourcesPagination: noopPagination, onPromptsUiChange: fn(), onGetPrompt: fn(), onRefreshPrompts: fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index eb770ee8..86907e2d 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -19,6 +19,7 @@ import { fireEvent, } from "../../../test/renderWithMantine"; import { InspectorView, type InspectorViewProps } from "./InspectorView"; +import { noopPagination } from "../../../test/fixtures/pagination"; // happy-dom's viewport is 1024px, narrower than the app's 1280px floor, so any // viewport media query would read "narrow". Force every `useMediaQuery` "wide" @@ -115,6 +116,9 @@ function makeProps( onToolsUiChange: vi.fn(), onCallTool: vi.fn(), onRefreshTools: vi.fn(), + toolsPagination: noopPagination, + promptsPagination: noopPagination, + resourcesPagination: noopPagination, onPromptsUiChange: vi.fn(), onGetPrompt: vi.fn(), onRefreshPrompts: vi.fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index af9e31e5..829ffdae 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -40,6 +40,7 @@ import { isAppTool } from "@inspector/core/mcp/apps.js"; import { ViewHeader } from "../../groups/ViewHeader/ViewHeader"; import { VersionBadge } from "../../elements/VersionBadge/VersionBadge"; import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge"; +import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen"; import { ToolsScreen, @@ -491,6 +492,12 @@ export interface InspectorViewProps { onCancelToolCall?: () => void; onClearToolResult?: () => void; onRefreshTools: () => void; + /** Pagination controls for the Tools list (#1721). */ + toolsPagination: ListPaginationControlsProps; + /** Pagination controls for the Prompts list (#1721). */ + promptsPagination: ListPaginationControlsProps; + /** Pagination controls for the Resources list (#1721). */ + resourcesPagination: ListPaginationControlsProps; /** * Read-on-demand handler for `resource_link` blocks in a tool result. * Returns the linked resource's contents so the result panel can inline them. @@ -625,6 +632,9 @@ export function InspectorView({ onClearToolResult, onReadResourceContents, onRefreshTools, + toolsPagination, + promptsPagination, + resourcesPagination, onPromptsUiChange, onGetPrompt, onCopyPromptMessages, @@ -1285,6 +1295,7 @@ export function InspectorView({ serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} onUiChange={onToolsUiChange} onRefreshList={onRefreshTools} + pagination={toolsPagination} onCallTool={onCallTool} onCancelCall={onCancelToolCall} onClearResult={onClearToolResult} @@ -1317,6 +1328,7 @@ export function InspectorView({ completionsSupported={completionsSupported} onUiChange={onPromptsUiChange} onRefreshList={onRefreshPrompts} + pagination={promptsPagination} onGetPrompt={onGetPrompt} onCopyMessages={onCopyPromptMessages} onCompleteArgument={onCompleteArgument} @@ -1334,6 +1346,7 @@ export function InspectorView({ subscriptionsSupported={subscriptionsSupported} onUiChange={onResourcesUiChange} onRefreshList={onRefreshResources} + pagination={resourcesPagination} onReadResource={onReadResource} onSubscribeResource={onSubscribeResource} onUnsubscribeResource={onUnsubscribeResource} diff --git a/clients/web/src/hooks/usePaginatedList.test.tsx b/clients/web/src/hooks/usePaginatedList.test.tsx new file mode 100644 index 00000000..558716e8 --- /dev/null +++ b/clients/web/src/hooks/usePaginatedList.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { usePaginatedList } from "./usePaginatedList"; + +interface Params { + connected: boolean; + paginated: boolean; + managedItems: string[]; + managedRefresh: () => Promise; + pagedItems: string[]; + pagedNextCursor?: string; + pagedPageCount: number; + loadPage: (cursor?: string) => Promise; +} + +function makeParams(over: Partial = {}): Params { + return { + connected: true, + paginated: false, + managedItems: ["m1", "m2"], + managedRefresh: vi.fn(async () => []), + pagedItems: ["p1"], + pagedNextCursor: undefined, + pagedPageCount: 0, + loadPage: vi.fn(async () => ({})), + ...over, + }; +} + +describe("usePaginatedList", () => { + it("shows the managed list and no paging in all-pages mode", () => { + const params = makeParams(); + const { result } = renderHook(() => usePaginatedList(params)); + expect(result.current.items).toEqual(["m1", "m2"]); + expect(result.current.paginated).toBe(false); + expect(result.current.canLoadMore).toBe(false); + }); + + it("shows the paged list and derives paging in paginated mode", () => { + const params = makeParams({ + paginated: true, + pagedNextCursor: "c1", + pagedPageCount: 2, + }); + const { result } = renderHook(() => usePaginatedList(params)); + expect(result.current.items).toEqual(["p1"]); + expect(result.current.canLoadMore).toBe(true); + expect(result.current.loadedPages).toBe(2); + }); + + it("masks paging progress while disconnected", () => { + const params = makeParams({ + connected: false, + paginated: true, + pagedNextCursor: "c1", + pagedPageCount: 2, + }); + const { result } = renderHook(() => usePaginatedList(params)); + expect(result.current.canLoadMore).toBe(false); + expect(result.current.loadedPages).toBe(0); + }); + + it("onLoadMore fetches the next page from the current cursor", () => { + const loadPage = vi.fn(async () => ({})); + const params = makeParams({ + paginated: true, + pagedNextCursor: "c1", + loadPage, + }); + const { result } = renderHook(() => usePaginatedList(params)); + void result.current.onLoadMore(); + expect(loadPage).toHaveBeenCalledWith("c1"); + }); + + it("onLoadMore is a no-op with no next cursor", () => { + const loadPage = vi.fn(async () => ({})); + const params = makeParams({ paginated: true, loadPage }); + const { result } = renderHook(() => usePaginatedList(params)); + void result.current.onLoadMore(); + expect(loadPage).not.toHaveBeenCalled(); + }); + + it("onRefresh reloads page 1 in paginated mode", () => { + const loadPage = vi.fn(async () => ({})); + const params = makeParams({ paginated: true, loadPage }); + const { result } = renderHook(() => usePaginatedList(params)); + void result.current.onRefresh(); + expect(loadPage).toHaveBeenCalledWith(undefined); + }); + + it("onRefresh re-fetches the aggregate in all-pages mode", () => { + const managedRefresh = vi.fn(async () => []); + const params = makeParams({ managedRefresh }); + const { result } = renderHook(() => usePaginatedList(params)); + void result.current.onRefresh(); + expect(managedRefresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/hooks/usePaginatedList.ts b/clients/web/src/hooks/usePaginatedList.ts new file mode 100644 index 00000000..7729e592 --- /dev/null +++ b/clients/web/src/hooks/usePaginatedList.ts @@ -0,0 +1,91 @@ +import { useCallback } from "react"; + +/** + * The list + pagination controls a screen renders, produced by + * {@link usePaginatedList}. `items` is already the correct source for the + * active mode (the aggregate list in all-pages mode, the accumulated paged + * list in paginated mode). + */ +export interface PaginatedListModel { + items: T[]; + /** True when fetching one page at a time (the `paginatedLists` setting). */ + paginated: boolean; + /** Paginated mode: the server returned a `nextCursor` still to load. */ + canLoadMore: boolean; + /** Paginated mode: pages loaded so far. */ + loadedPages: number; + /** + * Paginated mode: fetch the next page (no-op otherwise). Returns the load + * promise so the caller can wrap it in auth recovery. + */ + onLoadMore: () => Promise; + /** + * Refresh the list: reload page 1 in paginated mode, or re-fetch the whole + * aggregate in all-pages mode. This is what the list-changed indicator's + * Refresh button calls. Returns the underlying promise so the caller can wrap + * it in auth recovery. + */ + onRefresh: () => Promise; +} + +export interface UsePaginatedListParams { + /** Whether the client is connected (masks the paged progress when not). */ + connected: boolean; + /** The `paginatedLists` server setting (the active mode). */ + paginated: boolean; + /** The auto-aggregated list (all-pages mode display source). */ + managedItems: T[]; + /** Re-fetch the whole aggregate (all-pages mode Refresh). */ + managedRefresh: () => Promise; + /** The accumulated paged list (paginated mode display source). */ + pagedItems: T[]; + /** The paged store's current `nextCursor` (undefined = at the end). */ + pagedNextCursor?: string; + /** The paged store's page count (page 1 = 1). */ + pagedPageCount: number; + /** Fetch one page; `undefined` cursor = page 1 (replaces the paged list). */ + loadPage: (cursor?: string) => Promise; +} + +/** + * Select a list's display source and pagination controls between the managed + * (auto-aggregate-all-pages) and paged (one-page-at-a-time) state stores, + * driven by the `paginatedLists` server setting (#1721). + * + * Loading is owned by the state stores, not this hook: the paged store + * auto-loads page 1 on connect in paginated mode (and the managed store skips + * its all-page walk there), so this hook is pure — it derives the display list, + * the load-more affordance, and a mode-aware Refresh from store state. Mode + * *changes* (which trigger a load) are driven from the sidebar toggle handler + * in App, keeping data-loading out of React effects. + */ +export function usePaginatedList({ + connected, + paginated, + managedItems, + managedRefresh, + pagedItems, + pagedNextCursor, + pagedPageCount, + loadPage, +}: UsePaginatedListParams): PaginatedListModel { + const onLoadMore = useCallback((): Promise => { + if (pagedNextCursor === undefined) return Promise.resolve(); + return loadPage(pagedNextCursor); + }, [pagedNextCursor, loadPage]); + + const onRefresh = useCallback((): Promise => { + return paginated ? loadPage(undefined) : managedRefresh(); + }, [paginated, loadPage, managedRefresh]); + + return { + items: paginated ? pagedItems : managedItems, + paginated, + // Masked by `connected`: while disconnected there is no page to load and no + // meaningful page count (the store resets on disconnect). + canLoadMore: connected && paginated && pagedNextCursor !== undefined, + loadedPages: connected ? pagedPageCount : 0, + onLoadMore, + onRefresh, + }; +} diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 8e9cea02..6b6b6be7 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -251,6 +251,39 @@ describe("serverEntriesToMcpConfig", () => { expect(round).toEqual(original); }); + it("round-trips paginatedLists: lifts true to settings and back to disk", () => { + const original: MCPConfig = { + mcpServers: { + delta: { + type: "streamable-http", + url: "https://x.test/mcp", + paginatedLists: true, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.paginatedLists).toBe(true); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect(round).toEqual(original); + }); + + it("omits paginatedLists from disk when false (the default)", () => { + const original: MCPConfig = { + mcpServers: { + epsilon: { + type: "streamable-http", + url: "https://x.test/mcp", + connectionTimeout: 5000, + }, + }, + }; + const [entry] = mcpConfigToServerEntries(original); + expect(entry?.settings?.paginatedLists).toBe(false); + const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original)); + expect("paginatedLists" in (round.mcpServers.epsilon ?? {})).toBe(false); + expect(round).toEqual(original); + }); + it("round-trips maxFetchRequests: lifts a non-default value to settings and back to disk", () => { const original: MCPConfig = { mcpServers: { @@ -397,6 +430,8 @@ describe("serverEntriesToMcpConfig", () => { taskTtl: 60000, // Absent autoRefreshOnListChanged on disk → false in memory (for the form) autoRefreshOnListChanged: false, + // Absent paginatedLists on disk → false in memory (for the form) + paginatedLists: false, // Absent maxFetchRequests on disk → product default in memory (for the form) maxFetchRequests: 1000, // Absent roots on disk → empty list in memory (for the form) diff --git a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts index 57306820..17c1113f 100644 --- a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts @@ -35,7 +35,7 @@ describe("ManagedPromptsState", () => { beforeEach(() => { // Default to a server that advertises `prompts` so the existing flow tests - // exercise the live `listPrompts` path; capability-absent tests below + // exercise the live `listAllPrompts` path; capability-absent tests below // override this. client = new FakeInspectorClient({ capabilities: { prompts: {} } }); state = new ManagedPromptsState(client, 0); @@ -51,13 +51,13 @@ describe("ManagedPromptsState", () => { expect(a).not.toBe(b); }); - it("refresh returns early and does not call listPrompts when disconnected", async () => { + it("refresh returns early and does not call listAllPrompts when disconnected", async () => { const result = await state.refresh(); expect(result).toEqual([]); - expect(client.listPrompts).not.toHaveBeenCalled(); + expect(client.listAllPrompts).not.toHaveBeenCalled(); }); - it("refresh skips listPrompts when the server doesn't advertise prompts capability", async () => { + it("refresh skips listAllPrompts when the server doesn't advertise prompts capability", async () => { // Regression (#1350): a prompts-less server replied to prompts/list with // -32601 "Method not found", surfacing in the console on every connect. const promptless = new FakeInspectorClient({ @@ -68,10 +68,10 @@ describe("ManagedPromptsState", () => { const result = await promptlessState.refresh(); expect(result).toEqual([]); - expect(promptless.listPrompts).not.toHaveBeenCalled(); + expect(promptless.listAllPrompts).not.toHaveBeenCalled(); }); - it("connect against a prompts-less server doesn't fire listPrompts", async () => { + it("connect against a prompts-less server doesn't fire listAllPrompts", async () => { // The connect event runs refresh; the capability gate must also catch it // there, not only the publicly-callable refresh(). const promptless = new FakeInspectorClient({ capabilities: { tools: {} } }); @@ -81,11 +81,11 @@ describe("ManagedPromptsState", () => { const changePromise = waitForPromptsChange(promptlessState); promptless.dispatchTypedEvent("connect"); await changePromise; - expect(promptless.listPrompts).not.toHaveBeenCalled(); + expect(promptless.listAllPrompts).not.toHaveBeenCalled(); expect(promptlessState.getPrompts()).toEqual([]); }); - it("refresh fetches a single page and dispatches promptsChange", async () => { + it("refresh fetches the full list and dispatches promptsChange", async () => { client.setStatus("connected"); client.queuePromptPages({ prompts: [prompt("a"), prompt("b")] }); @@ -97,7 +97,9 @@ describe("ManagedPromptsState", () => { expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]); }); - it("refresh accumulates across multiple paginated pages", async () => { + it("refresh delegates all-page aggregation to listAllPrompts (one call)", async () => { + // The SDK's high-level verb walks every page; the managed state makes a + // single `listAllPrompts` call rather than looping single pages itself. client.setStatus("connected"); client.queuePromptPages( { prompts: [prompt("a")], nextCursor: "c1" }, @@ -107,15 +109,18 @@ describe("ManagedPromptsState", () => { const result = await state.refresh(); expect(result.map((p) => p.name)).toEqual(["a", "b", "c"]); - expect(client.listPrompts).toHaveBeenCalledTimes(3); + expect(client.listAllPrompts).toHaveBeenCalledTimes(1); }); - it("refresh passes setMetadata-supplied metadata to listPrompts", async () => { + it("refresh passes setMetadata-supplied metadata to listAllPrompts", async () => { client.setStatus("connected"); state.setMetadata({ k: "v" }); client.queuePromptPages({ prompts: [prompt("a")] }); await state.refresh(); - expect(client.listPrompts).toHaveBeenCalledWith(undefined, { k: "v" }); + expect(client.listAllPrompts).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "v" }, + }); }); it("refresh argument overrides setMetadata", async () => { @@ -123,8 +128,9 @@ describe("ManagedPromptsState", () => { state.setMetadata({ k: "default" }); client.queuePromptPages({ prompts: [prompt("a")] }); await state.refresh({ k: "override" }); - expect(client.listPrompts).toHaveBeenCalledWith(undefined, { - k: "override", + expect(client.listAllPrompts).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "override" }, }); }); @@ -144,11 +150,11 @@ describe("ManagedPromptsState", () => { const changed = waitForListChanged(state); client.dispatchTypedEvent("promptsListChanged"); expect(await changed).toBe(true); - expect(client.listPrompts).not.toHaveBeenCalled(); // no automatic fetch + expect(client.listAllPrompts).not.toHaveBeenCalled(); // no automatic fetch expect(state.getPrompts()).toEqual([]); // displayed list untouched }); - it("promptsListChanged auto-refreshes when the server opts in", async () => { + it("promptsListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => { const autoClient = new FakeInspectorClient({ capabilities: { prompts: {} }, serverSettings: AUTO_REFRESH_SETTINGS, @@ -159,7 +165,11 @@ describe("ManagedPromptsState", () => { const changed = waitForPromptsChange(autoState); autoClient.dispatchTypedEvent("promptsListChanged"); expect((await changed).map((p) => p.name)).toEqual(["a"]); - expect(autoClient.listPrompts).toHaveBeenCalled(); + // A list_changed means the prior list is stale → bypass the cache. + expect(autoClient.listAllPrompts).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); }); it("statusChange to disconnected clears prompts and dispatches promptsChange", async () => { @@ -196,13 +206,16 @@ describe("ManagedPromptsState", () => { expect(state.getPrompts().map((p) => p.name)).toEqual(["a"]); }); - it("throws when pagination exceeds 100 pages", async () => { + it("refresh forwards an explicit cacheMode to listAllPrompts", async () => { + // A user-initiated refresh (via the hook) passes cacheMode:"refresh" to + // force a cache-bypassing round trip on modern servers (#1721). client.setStatus("connected"); - client.listPrompts.mockImplementation(async () => ({ - prompts: [prompt("a")], - nextCursor: "always", - })); - await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/); + client.queuePromptPages({ prompts: [prompt("a")] }); + await state.refresh(undefined, "refresh"); + expect(client.listAllPrompts).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); }); it("destroy unsubscribes from client events and clears state", async () => { diff --git a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts index 8aaae543..c4850760 100644 --- a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts @@ -33,7 +33,7 @@ describe("ManagedResourceTemplatesState", () => { beforeEach(() => { // Default to a server that advertises `resources` so the existing flow - // tests exercise the live `listResourceTemplates` path; capability-absent + // tests exercise the live `listAllResourceTemplates` path; capability-absent // tests below override this. (Templates are gated on the `resources` // capability — the spec defines no separate `resourceTemplates` one.) client = new FakeInspectorClient({ capabilities: { resources: {} } }); @@ -50,13 +50,13 @@ describe("ManagedResourceTemplatesState", () => { expect(a).not.toBe(b); }); - it("refresh returns early and does not call listResourceTemplates when disconnected", async () => { + it("refresh returns early and does not call listAllResourceTemplates when disconnected", async () => { const result = await state.refresh(); expect(result).toEqual([]); - expect(client.listResourceTemplates).not.toHaveBeenCalled(); + expect(client.listAllResourceTemplates).not.toHaveBeenCalled(); }); - it("refresh skips listResourceTemplates when the server doesn't advertise resources capability", async () => { + it("refresh skips listAllResourceTemplates when the server doesn't advertise resources capability", async () => { // Regression (#1350): templates are part of the resources surface, so a // resources-less server replied to resources/templates/list with -32601 // "Method not found", surfacing in the console on every connect. @@ -71,10 +71,10 @@ describe("ManagedResourceTemplatesState", () => { const result = await resourcelessState.refresh(); expect(result).toEqual([]); - expect(resourceless.listResourceTemplates).not.toHaveBeenCalled(); + expect(resourceless.listAllResourceTemplates).not.toHaveBeenCalled(); }); - it("connect against a resources-less server doesn't fire listResourceTemplates", async () => { + it("connect against a resources-less server doesn't fire listAllResourceTemplates", async () => { // The connect event runs refresh; the capability gate must also catch it // there, not only the publicly-callable refresh(). const resourceless = new FakeInspectorClient({ @@ -89,11 +89,11 @@ describe("ManagedResourceTemplatesState", () => { const changePromise = waitForChange(resourcelessState); resourceless.dispatchTypedEvent("connect"); await changePromise; - expect(resourceless.listResourceTemplates).not.toHaveBeenCalled(); + expect(resourceless.listAllResourceTemplates).not.toHaveBeenCalled(); expect(resourcelessState.getResourceTemplates()).toEqual([]); }); - it("refresh fetches a single page and dispatches resourceTemplatesChange", async () => { + it("refresh fetches the full list and dispatches resourceTemplatesChange", async () => { client.setStatus("connected"); client.queueResourceTemplatePages({ resourceTemplates: [template("a"), template("b")], @@ -107,7 +107,9 @@ describe("ManagedResourceTemplatesState", () => { expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a", "b"]); }); - it("refresh accumulates across multiple paginated pages", async () => { + it("refresh delegates all-page aggregation to listAllResourceTemplates (one call)", async () => { + // The SDK's high-level verb walks every page; the managed state makes a + // single `listAllResourceTemplates` call rather than looping single pages itself. client.setStatus("connected"); client.queueResourceTemplatePages( { resourceTemplates: [template("a")], nextCursor: "c1" }, @@ -117,7 +119,7 @@ describe("ManagedResourceTemplatesState", () => { const result = await state.refresh(); expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]); - expect(client.listResourceTemplates).toHaveBeenCalledTimes(3); + expect(client.listAllResourceTemplates).toHaveBeenCalledTimes(1); }); it("refresh passes setMetadata-supplied metadata", async () => { @@ -125,8 +127,9 @@ describe("ManagedResourceTemplatesState", () => { state.setMetadata({ k: "v" }); client.queueResourceTemplatePages({ resourceTemplates: [template("a")] }); await state.refresh(); - expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, { - k: "v", + expect(client.listAllResourceTemplates).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "v" }, }); }); @@ -135,8 +138,9 @@ describe("ManagedResourceTemplatesState", () => { state.setMetadata({ k: "default" }); client.queueResourceTemplatePages({ resourceTemplates: [template("a")] }); await state.refresh({ k: "override" }); - expect(client.listResourceTemplates).toHaveBeenCalledWith(undefined, { - k: "override", + expect(client.listAllResourceTemplates).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "override" }, }); }); @@ -149,6 +153,25 @@ describe("ManagedResourceTemplatesState", () => { expect(next.map((t) => t.name)).toEqual(["a"]); }); + it("STILL loads on connect in paginated mode (no paged counterpart, #1721)", async () => { + // Unlike tools/prompts/resources, resource templates have no paged + // counterpart, so they must aggregate on connect even when + // `paginatedLists` is on — otherwise the list would be empty until a + // manual refresh (deferWhenPaginated: false). + const spClient = new FakeInspectorClient({ + capabilities: { resources: {} }, + serverSettings: { ...AUTO_REFRESH_SETTINGS, paginatedLists: true }, + }); + spClient.setStatus("connected"); + const spState = new ManagedResourceTemplatesState(spClient, 0); + spClient.queueResourceTemplatePages({ resourceTemplates: [template("a")] }); + const changePromise = waitForChange(spState); + spClient.dispatchTypedEvent("connect"); + expect((await changePromise).map((t) => t.name)).toEqual(["a"]); + expect(spClient.listAllResourceTemplates).toHaveBeenCalled(); + spState.destroy(); + }); + it("resourceTemplatesListChanged does NOT auto-refresh by default (refreshed via the Resources Refresh)", async () => { client.setStatus("connected"); client.queueResourceTemplatePages({ @@ -158,11 +181,11 @@ describe("ManagedResourceTemplatesState", () => { // Yield so a stray refresh would have landed. await Promise.resolve(); await Promise.resolve(); - expect(client.listResourceTemplates).not.toHaveBeenCalled(); + expect(client.listAllResourceTemplates).not.toHaveBeenCalled(); expect(state.getResourceTemplates()).toEqual([]); }); - it("resourceTemplatesListChanged auto-refreshes when the server opts in", async () => { + it("resourceTemplatesListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => { const autoClient = new FakeInspectorClient({ capabilities: { resources: {} }, serverSettings: AUTO_REFRESH_SETTINGS, @@ -175,7 +198,11 @@ describe("ManagedResourceTemplatesState", () => { const changed = waitForChange(autoState); autoClient.dispatchTypedEvent("resourceTemplatesListChanged"); expect((await changed).map((t) => t.name)).toEqual(["a"]); - expect(autoClient.listResourceTemplates).toHaveBeenCalled(); + // A list_changed means the prior list is stale → bypass the cache. + expect(autoClient.listAllResourceTemplates).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); }); it("statusChange to disconnected clears templates and dispatches change", async () => { @@ -212,13 +239,16 @@ describe("ManagedResourceTemplatesState", () => { expect(state.getResourceTemplates().map((t) => t.name)).toEqual(["a"]); }); - it("throws when pagination exceeds 100 pages", async () => { + it("refresh forwards an explicit cacheMode to listAllResourceTemplates", async () => { + // A user-initiated refresh (via the hook) passes cacheMode:"refresh" to + // force a cache-bypassing round trip on modern servers (#1721). client.setStatus("connected"); - client.listResourceTemplates.mockImplementation(async () => ({ - resourceTemplates: [template("a")], - nextCursor: "always", - })); - await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/); + client.queueResourceTemplatePages({ resourceTemplates: [template("a")] }); + await state.refresh(undefined, "refresh"); + expect(client.listAllResourceTemplates).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); }); it("destroy unsubscribes from client events and clears state", async () => { diff --git a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts index d0258617..3e82467c 100644 --- a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts @@ -37,7 +37,7 @@ describe("ManagedResourcesState", () => { beforeEach(() => { // Default to a server that advertises `resources` so the existing flow - // tests exercise the live `listResources` path; capability-absent tests + // tests exercise the live `listAllResources` path; capability-absent tests // below override this. client = new FakeInspectorClient({ capabilities: { resources: {} } }); state = new ManagedResourcesState(client, 0); @@ -53,13 +53,13 @@ describe("ManagedResourcesState", () => { expect(a).not.toBe(b); }); - it("refresh returns early and does not call listResources when disconnected", async () => { + it("refresh returns early and does not call listAllResources when disconnected", async () => { const result = await state.refresh(); expect(result).toEqual([]); - expect(client.listResources).not.toHaveBeenCalled(); + expect(client.listAllResources).not.toHaveBeenCalled(); }); - it("refresh skips listResources when the server doesn't advertise resources capability", async () => { + it("refresh skips listAllResources when the server doesn't advertise resources capability", async () => { // Regression (#1350): a resources-less server replied to resources/list // with -32601 "Method not found", surfacing in the console on every // connect. @@ -71,10 +71,10 @@ describe("ManagedResourcesState", () => { const result = await resourcelessState.refresh(); expect(result).toEqual([]); - expect(resourceless.listResources).not.toHaveBeenCalled(); + expect(resourceless.listAllResources).not.toHaveBeenCalled(); }); - it("connect against a resources-less server doesn't fire listResources", async () => { + it("connect against a resources-less server doesn't fire listAllResources", async () => { // The connect event runs refresh; the capability gate must also catch it // there, not only the publicly-callable refresh(). const resourceless = new FakeInspectorClient({ @@ -86,11 +86,11 @@ describe("ManagedResourcesState", () => { const changePromise = waitForResourcesChange(resourcelessState); resourceless.dispatchTypedEvent("connect"); await changePromise; - expect(resourceless.listResources).not.toHaveBeenCalled(); + expect(resourceless.listAllResources).not.toHaveBeenCalled(); expect(resourcelessState.getResources()).toEqual([]); }); - it("refresh fetches a single page and dispatches resourcesChange", async () => { + it("refresh fetches the full list and dispatches resourcesChange", async () => { client.setStatus("connected"); client.queueResourcePages({ resources: [resource("a://1"), resource("a://2")], @@ -104,7 +104,9 @@ describe("ManagedResourcesState", () => { expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]); }); - it("refresh accumulates across multiple paginated pages", async () => { + it("refresh delegates all-page aggregation to listAllResources (one call)", async () => { + // The SDK's high-level verb walks every page; the managed state makes a + // single `listAllResources` call rather than looping single pages itself. client.setStatus("connected"); client.queueResourcePages( { resources: [resource("a://1")], nextCursor: "c1" }, @@ -114,15 +116,18 @@ describe("ManagedResourcesState", () => { const result = await state.refresh(); expect(result.map((r) => r.uri)).toEqual(["a://1", "a://2", "a://3"]); - expect(client.listResources).toHaveBeenCalledTimes(3); + expect(client.listAllResources).toHaveBeenCalledTimes(1); }); - it("refresh passes setMetadata-supplied metadata to listResources", async () => { + it("refresh passes setMetadata-supplied metadata to listAllResources", async () => { client.setStatus("connected"); state.setMetadata({ k: "v" }); client.queueResourcePages({ resources: [resource("a://1")] }); await state.refresh(); - expect(client.listResources).toHaveBeenCalledWith(undefined, { k: "v" }); + expect(client.listAllResources).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "v" }, + }); }); it("refresh argument overrides setMetadata", async () => { @@ -130,8 +135,9 @@ describe("ManagedResourcesState", () => { state.setMetadata({ k: "default" }); client.queueResourcePages({ resources: [resource("a://1")] }); await state.refresh({ k: "override" }); - expect(client.listResources).toHaveBeenCalledWith(undefined, { - k: "override", + expect(client.listAllResources).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "override" }, }); }); @@ -151,11 +157,11 @@ describe("ManagedResourcesState", () => { const changed = waitForListChanged(state); client.dispatchTypedEvent("resourcesListChanged"); expect(await changed).toBe(true); - expect(client.listResources).not.toHaveBeenCalled(); // no automatic fetch + expect(client.listAllResources).not.toHaveBeenCalled(); // no automatic fetch expect(state.getResources()).toEqual([]); // displayed list untouched }); - it("resourcesListChanged auto-refreshes when the server opts in", async () => { + it("resourcesListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => { const autoClient = new FakeInspectorClient({ capabilities: { resources: {} }, serverSettings: AUTO_REFRESH_SETTINGS, @@ -166,7 +172,11 @@ describe("ManagedResourcesState", () => { const changed = waitForResourcesChange(autoState); autoClient.dispatchTypedEvent("resourcesListChanged"); expect((await changed).map((r) => r.uri)).toEqual(["a://1"]); - expect(autoClient.listResources).toHaveBeenCalled(); + // A list_changed means the prior list is stale → bypass the cache. + expect(autoClient.listAllResources).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); }); it("statusChange to disconnected clears resources and dispatches resourcesChange", async () => { @@ -203,13 +213,16 @@ describe("ManagedResourcesState", () => { expect(state.getResources().map((r) => r.uri)).toEqual(["a://1"]); }); - it("throws when pagination exceeds 100 pages", async () => { + it("refresh forwards an explicit cacheMode to listAllResources", async () => { + // A user-initiated refresh (via the hook) passes cacheMode:"refresh" to + // force a cache-bypassing round trip on modern servers (#1721). client.setStatus("connected"); - client.listResources.mockImplementation(async () => ({ - resources: [resource("a://1")], - nextCursor: "always", - })); - await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/); + client.queueResourcePages({ resources: [resource("a://1")] }); + await state.refresh(undefined, "refresh"); + expect(client.listAllResources).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); }); it("destroy unsubscribes from client events and clears state", async () => { diff --git a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts index 1ff01acf..449c1b24 100644 --- a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts @@ -21,6 +21,13 @@ const AUTO_REFRESH_SETTINGS: InspectorServerSettings = { roots: [], }; +// Paginated mode on, plus auto-refresh on — paginated must win so the +// aggregate walk never runs (#1721). +const PAGINATED_SETTINGS: InspectorServerSettings = { + ...AUTO_REFRESH_SETTINGS, + paginatedLists: true, +}; + function waitForToolsChange(state: ManagedToolsState): Promise { return waitForChangeEvent(state, "toolsChange"); } @@ -35,7 +42,7 @@ describe("ManagedToolsState", () => { beforeEach(() => { // Default to a server that advertises `tools` so the existing flow tests - // exercise the live `listTools` path; capability-absent tests below + // exercise the live `listAllTools` path; capability-absent tests below // override this. client = new FakeInspectorClient({ capabilities: { tools: {} } }); state = new ManagedToolsState(client, 0); @@ -51,13 +58,13 @@ describe("ManagedToolsState", () => { expect(a).not.toBe(b); }); - it("refresh returns early and does not call listTools when disconnected", async () => { + it("refresh returns early and does not call listAllTools when disconnected", async () => { const result = await state.refresh(); expect(result).toEqual([]); - expect(client.listTools).not.toHaveBeenCalled(); + expect(client.listAllTools).not.toHaveBeenCalled(); }); - it("refresh skips listTools when the server doesn't advertise tools capability", async () => { + it("refresh skips listAllTools when the server doesn't advertise tools capability", async () => { // Regression (#1350): a tools-less server replied to tools/list with // -32601 "Method not found", surfacing in the console on every connect. const toolless = new FakeInspectorClient({ @@ -68,10 +75,10 @@ describe("ManagedToolsState", () => { const result = await toollessState.refresh(); expect(result).toEqual([]); - expect(toolless.listTools).not.toHaveBeenCalled(); + expect(toolless.listAllTools).not.toHaveBeenCalled(); }); - it("connect against a tools-less server doesn't fire listTools", async () => { + it("connect against a tools-less server doesn't fire listAllTools", async () => { // The connect event runs refresh; the capability gate must also catch it // there, not only the publicly-callable refresh(). const toolless = new FakeInspectorClient({ capabilities: { prompts: {} } }); @@ -81,11 +88,11 @@ describe("ManagedToolsState", () => { const changePromise = waitForToolsChange(toollessState); toolless.dispatchTypedEvent("connect"); await changePromise; - expect(toolless.listTools).not.toHaveBeenCalled(); + expect(toolless.listAllTools).not.toHaveBeenCalled(); expect(toollessState.getTools()).toEqual([]); }); - it("refresh fetches a single page and dispatches toolsChange", async () => { + it("refresh fetches the full list and dispatches toolsChange", async () => { client.setStatus("connected"); client.queueToolPages({ tools: [tool("a"), tool("b")] }); @@ -97,7 +104,9 @@ describe("ManagedToolsState", () => { expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]); }); - it("refresh accumulates across multiple paginated pages", async () => { + it("refresh delegates all-page aggregation to listAllTools (one call)", async () => { + // The SDK's high-level verb walks every page; the managed state makes a + // single `listAllTools` call rather than looping single pages itself. client.setStatus("connected"); client.queueToolPages( { tools: [tool("a")], nextCursor: "c1" }, @@ -107,15 +116,18 @@ describe("ManagedToolsState", () => { const result = await state.refresh(); expect(result.map((t) => t.name)).toEqual(["a", "b", "c"]); - expect(client.listTools).toHaveBeenCalledTimes(3); + expect(client.listAllTools).toHaveBeenCalledTimes(1); }); - it("refresh passes setMetadata-supplied metadata to listTools", async () => { + it("refresh passes setMetadata-supplied metadata to listAllTools", async () => { client.setStatus("connected"); state.setMetadata({ k: "v" }); client.queueToolPages({ tools: [tool("a")] }); await state.refresh(); - expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "v" }); + expect(client.listAllTools).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "v" }, + }); }); it("refresh argument overrides setMetadata", async () => { @@ -123,7 +135,55 @@ describe("ManagedToolsState", () => { state.setMetadata({ k: "default" }); client.queueToolPages({ tools: [tool("a")] }); await state.refresh({ k: "override" }); - expect(client.listTools).toHaveBeenCalledWith(undefined, { k: "override" }); + expect(client.listAllTools).toHaveBeenCalledWith({ + cacheMode: undefined, + metadata: { k: "override" }, + }); + }); + + it("refresh forwards an explicit cacheMode to listAllTools", async () => { + // A user-initiated refresh (via the hook) passes cacheMode:"refresh" to + // force a cache-bypassing round trip on modern servers (#1721). + client.setStatus("connected"); + client.queueToolPages({ tools: [tool("a")] }); + await state.refresh(undefined, "refresh"); + expect(client.listAllTools).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); + }); + + it("connect does NOT fetch in paginated mode (#1721)", async () => { + // The aggregate list isn't the display source in paginated mode, so the + // connect-time all-page walk is skipped (the defensive point of the setting). + const spClient = new FakeInspectorClient({ + capabilities: { tools: {} }, + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new ManagedToolsState(spClient, 0); + spClient.queueToolPages({ tools: [tool("a")] }); + spClient.dispatchTypedEvent("connect"); + await new Promise((r) => setTimeout(r, 0)); + expect(spClient.listAllTools).not.toHaveBeenCalled(); + expect(spState.getTools()).toEqual([]); + spState.destroy(); + }); + + it("list_changed only lights the indicator in paginated mode, never aggregates (#1721)", async () => { + // Paginated wins over autoRefreshOnListChanged: the indicator lights but + // the aggregate walk never runs. + const spClient = new FakeInspectorClient({ + capabilities: { tools: {} }, + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new ManagedToolsState(spClient, 0); + const changed = waitForListChanged(spState); + spClient.dispatchTypedEvent("toolsListChanged"); + expect(await changed).toBe(true); + expect(spClient.listAllTools).not.toHaveBeenCalled(); + spState.destroy(); }); it("connect event triggers a refresh", async () => { @@ -142,7 +202,7 @@ describe("ManagedToolsState", () => { const changed = waitForListChanged(state); client.dispatchTypedEvent("toolsListChanged"); expect(await changed).toBe(true); - expect(client.listTools).not.toHaveBeenCalled(); // no automatic fetch + expect(client.listAllTools).not.toHaveBeenCalled(); // no automatic fetch expect(state.getTools()).toEqual([]); // displayed list untouched }); @@ -160,7 +220,7 @@ describe("ManagedToolsState", () => { await new Promise((r) => setTimeout(r, 0)); expect(fired).toBe(1); // one debounced flip expect(state.getListChanged()).toBe(true); - expect(client.listTools).not.toHaveBeenCalled(); + expect(client.listAllTools).not.toHaveBeenCalled(); }); it("coalesces an auto-refresh that fires while an earlier one is still fetching (#1444)", async () => { @@ -173,7 +233,7 @@ describe("ManagedToolsState", () => { autoClient.setStatus("connected"); const autoState = new ManagedToolsState(autoClient, 0); let release: (value: { tools: Tool[] }) => void = () => {}; - autoClient.listTools.mockImplementationOnce( + autoClient.listAllTools.mockImplementationOnce( () => new Promise<{ tools: Tool[] }>((resolve) => { release = resolve; @@ -183,20 +243,20 @@ describe("ManagedToolsState", () => { autoClient.dispatchTypedEvent("toolsListChanged"); await new Promise((r) => setTimeout(r, 0)); - expect(autoClient.listTools).toHaveBeenCalledTimes(1); + expect(autoClient.listAllTools).toHaveBeenCalledTimes(1); autoClient.dispatchTypedEvent("toolsListChanged"); await new Promise((r) => setTimeout(r, 0)); - expect(autoClient.listTools).toHaveBeenCalledTimes(1); // coalesced + expect(autoClient.listAllTools).toHaveBeenCalledTimes(1); // coalesced release({ tools: [tool("a")] }); await new Promise((r) => setTimeout(r, 0)); - expect(autoClient.listTools).toHaveBeenCalledTimes(2); + expect(autoClient.listAllTools).toHaveBeenCalledTimes(2); // The (coalesced) auto-refresh applied the new list. expect(autoState.getTools().map((t) => t.name)).toEqual(["a"]); }); - it("toolsListChanged auto-refreshes when the server opts in", async () => { + it("toolsListChanged auto-refreshes (cacheMode:refresh) when the server opts in", async () => { const autoClient = new FakeInspectorClient({ capabilities: { tools: {} }, serverSettings: AUTO_REFRESH_SETTINGS, @@ -207,7 +267,11 @@ describe("ManagedToolsState", () => { const changed = waitForToolsChange(autoState); autoClient.dispatchTypedEvent("toolsListChanged"); expect((await changed).map((t) => t.name)).toEqual(["a"]); - expect(autoClient.listTools).toHaveBeenCalled(); + // A list_changed means the prior list is stale → bypass the cache. + expect(autoClient.listAllTools).toHaveBeenCalledWith({ + cacheMode: "refresh", + metadata: undefined, + }); }); it("honors a live setServerSettings toggle without a reconnect (#1444)", async () => { @@ -256,14 +320,27 @@ describe("ManagedToolsState", () => { expect(state.getTools().map((t) => t.name)).toEqual(["a"]); }); - it("throws when pagination exceeds 100 pages", async () => { + it("clears a pending list_changed debounce timer when the connection goes terminal", async () => { + // A non-zero debounce leaves the timer pending; a terminal statusChange must + // clear it so no stale indicator/fetch fires after disconnect. client.setStatus("connected"); - // Always returns a non-terminal cursor; refresh should bail out at the cap. - client.listTools.mockImplementation(async () => ({ - tools: [tool("a")], - nextCursor: "always", - })); - await expect(state.refresh()).rejects.toThrow(/Maximum pagination limit/); + const debounced = new ManagedToolsState(client, 1000); + client.dispatchTypedEvent("toolsListChanged"); // schedules the debounce timer + client.setStatus("disconnected"); // terminal → clears the pending timer + await new Promise((r) => setTimeout(r, 5)); + expect(debounced.getListChanged()).toBe(false); + debounced.destroy(); + }); + + it("clears a pending list_changed debounce timer on destroy", async () => { + client.setStatus("connected"); + const debounced = new ManagedToolsState(client, 1000); + client.dispatchTypedEvent("toolsListChanged"); // schedules the debounce timer + debounced.destroy(); // unsubscribe clears the pending timer + await new Promise((r) => setTimeout(r, 5)); + // No indicator lit and the list stays empty — the timer never fired. + expect(debounced.getListChanged()).toBe(false); + expect(debounced.getTools()).toEqual([]); }); it("destroy unsubscribes from client events and clears state", async () => { diff --git a/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts index 9df6895c..40584cf9 100644 --- a/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { Prompt } from "@modelcontextprotocol/client"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { PagedPromptsState } from "@inspector/core/mcp/state/pagedPromptsState"; import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; @@ -7,6 +8,18 @@ function prompt(name: string): Prompt { return { name }; } +const PAGINATED_SETTINGS: InspectorServerSettings = { + headers: [], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + paginatedLists: true, +}; + function waitForChange(state: PagedPromptsState): Promise { return new Promise((resolve) => { state.addEventListener("promptsChange", (e) => resolve(e.detail), { @@ -111,4 +124,81 @@ describe("PagedPromptsState", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); }); + + it("ignores a concurrent loadPage (double-click guard, #1721)", async () => { + client.setStatus("connected"); + client.queuePromptPages( + { prompts: [prompt("a")], nextCursor: "c1" }, + { prompts: [prompt("b")], nextCursor: "c2" }, + ); + await state.loadPage(); + const [r1, r2] = await Promise.all([ + state.loadPage("c1"), + state.loadPage("c1"), + ]); + expect(r1.prompts.map((p) => p.name)).toEqual(["b"]); + expect(r2.prompts).toEqual([]); + expect(r2.nextCursor).toBe("c1"); + expect(state.getPrompts().map((p) => p.name)).toEqual(["a", "b"]); + expect(state.getPagination().pageCount).toBe(2); + }); + + describe("pagination progress + connect auto-load (#1721)", () => { + it("tracks nextCursor/page count and dispatches paginationChange", async () => { + client.setStatus("connected"); + client.queuePromptPages( + { prompts: [prompt("a")], nextCursor: "c1" }, + { prompts: [prompt("b")] }, + ); + const onLoad = new Promise<{ nextCursor?: string; pageCount: number }>( + (resolve) => { + state.addEventListener("paginationChange", (e) => resolve(e.detail), { + once: true, + }); + }, + ); + await state.loadPage(); + expect(await onLoad).toEqual({ nextCursor: "c1", pageCount: 1 }); + await state.loadPage("c1"); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 2, + }); + }); + + it("resets pagination on disconnect and clear", async () => { + client.setStatus("connected"); + client.queuePromptPages({ prompts: [prompt("a")], nextCursor: "c1" }); + await state.loadPage(); + state.clear(); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 0, + }); + await state.loadPage(); + client.setStatus("disconnected"); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 0, + }); + }); + + it("loads page 1 on connect in paginated mode, not otherwise", async () => { + const spClient = new FakeInspectorClient({ + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new PagedPromptsState(spClient); + spClient.queuePromptPages({ prompts: [prompt("a")] }); + const changed = waitForChange(spState); + spClient.dispatchTypedEvent("connect"); + expect((await changed).map((p) => p.name)).toEqual(["a"]); + spState.destroy(); + + client.setStatus("connected"); + client.dispatchTypedEvent("connect"); + await Promise.resolve(); + expect(client.listPrompts).not.toHaveBeenCalled(); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts index 5a8dbec4..c60d75a3 100644 --- a/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { Resource } from "@modelcontextprotocol/client"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { PagedResourcesState } from "@inspector/core/mcp/state/pagedResourcesState"; import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; @@ -7,6 +8,18 @@ function resource(uri: string): Resource { return { uri, name: uri }; } +const PAGINATED_SETTINGS: InspectorServerSettings = { + headers: [], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + paginatedLists: true, +}; + function waitForChange(state: PagedResourcesState): Promise { return new Promise((resolve) => { state.addEventListener("resourcesChange", (e) => resolve(e.detail), { @@ -113,4 +126,84 @@ describe("PagedResourcesState", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); }); + + it("ignores a concurrent loadPage (double-click guard, #1721)", async () => { + client.setStatus("connected"); + client.queueResourcePages( + { resources: [resource("a://1")], nextCursor: "c1" }, + { resources: [resource("a://2")], nextCursor: "c2" }, + ); + await state.loadPage(); + const [r1, r2] = await Promise.all([ + state.loadPage("c1"), + state.loadPage("c1"), + ]); + expect(r1.resources.map((r) => r.uri)).toEqual(["a://2"]); + expect(r2.resources).toEqual([]); + expect(r2.nextCursor).toBe("c1"); + expect(state.getResources().map((r) => r.uri)).toEqual(["a://1", "a://2"]); + expect(state.getPagination().pageCount).toBe(2); + }); + + describe("pagination progress + connect auto-load (#1721)", () => { + it("tracks nextCursor/page count and dispatches paginationChange", async () => { + client.setStatus("connected"); + client.queueResourcePages( + { resources: [resource("a://1")], nextCursor: "c1" }, + { resources: [resource("a://2")] }, + ); + const onLoad = new Promise<{ nextCursor?: string; pageCount: number }>( + (resolve) => { + state.addEventListener("paginationChange", (e) => resolve(e.detail), { + once: true, + }); + }, + ); + await state.loadPage(); + expect(await onLoad).toEqual({ nextCursor: "c1", pageCount: 1 }); + await state.loadPage("c1"); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 2, + }); + }); + + it("resets pagination on disconnect and clear", async () => { + client.setStatus("connected"); + client.queueResourcePages({ + resources: [resource("a://1")], + nextCursor: "c1", + }); + await state.loadPage(); + state.clear(); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 0, + }); + await state.loadPage(); + client.setStatus("disconnected"); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 0, + }); + }); + + it("loads page 1 on connect in paginated mode, not otherwise", async () => { + const spClient = new FakeInspectorClient({ + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new PagedResourcesState(spClient); + spClient.queueResourcePages({ resources: [resource("a://1")] }); + const changed = waitForChange(spState); + spClient.dispatchTypedEvent("connect"); + expect((await changed).map((r) => r.uri)).toEqual(["a://1"]); + spState.destroy(); + + client.setStatus("connected"); + client.dispatchTypedEvent("connect"); + await Promise.resolve(); + expect(client.listResources).not.toHaveBeenCalled(); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts b/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts index 23c31af7..fb1509d5 100644 --- a/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { Tool } from "@modelcontextprotocol/client"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { PagedToolsState } from "@inspector/core/mcp/state/pagedToolsState"; import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; @@ -7,6 +8,18 @@ function tool(name: string): Tool { return { name, inputSchema: { type: "object" } }; } +const PAGINATED_SETTINGS: InspectorServerSettings = { + headers: [], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + paginatedLists: true, +}; + function waitForChange(state: PagedToolsState): Promise { return new Promise((resolve) => { state.addEventListener("toolsChange", (e) => resolve(e.detail), { @@ -120,4 +133,109 @@ describe("PagedToolsState", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); }); + + describe("pagination progress (#1721)", () => { + it("tracks nextCursor and page count across loads", async () => { + client.setStatus("connected"); + client.queueToolPages( + { tools: [tool("a")], nextCursor: "c1" }, + { tools: [tool("b")] }, + ); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 0, + }); + await state.loadPage(); + expect(state.getPagination()).toEqual({ nextCursor: "c1", pageCount: 1 }); + await state.loadPage("c1"); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 2, + }); + }); + + it("dispatches paginationChange on load and reset", async () => { + client.setStatus("connected"); + client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" }); + const onLoad = new Promise<{ nextCursor?: string; pageCount: number }>( + (resolve) => { + state.addEventListener("paginationChange", (e) => resolve(e.detail), { + once: true, + }); + }, + ); + await state.loadPage(); + expect(await onLoad).toEqual({ nextCursor: "c1", pageCount: 1 }); + + const onClear = new Promise<{ nextCursor?: string; pageCount: number }>( + (resolve) => { + state.addEventListener("paginationChange", (e) => resolve(e.detail), { + once: true, + }); + }, + ); + state.clear(); + expect(await onClear).toEqual({ nextCursor: undefined, pageCount: 0 }); + }); + + it("resets pagination when the connection goes terminal", async () => { + client.setStatus("connected"); + client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" }); + await state.loadPage(); + client.setStatus("disconnected"); + expect(state.getPagination()).toEqual({ + nextCursor: undefined, + pageCount: 0, + }); + }); + }); + + it("ignores a concurrent loadPage (double-click guard, #1721)", async () => { + // Two overlapping loadPage(cursor) calls must not both append the same + // page. The second (while the first is in flight) is a no-op that returns + // the current cursor. + client.setStatus("connected"); + client.queueToolPages( + { tools: [tool("a")], nextCursor: "c1" }, + { tools: [tool("b")], nextCursor: "c2" }, + ); + await state.loadPage(); // page 1 → cursor c1 + const first = state.loadPage("c1"); + const second = state.loadPage("c1"); // concurrent — should be dropped + const [r1, r2] = await Promise.all([first, second]); + expect(r1.tools.map((t) => t.name)).toEqual(["b"]); + expect(r2.tools).toEqual([]); // guarded no-op + expect(r2.nextCursor).toBe("c1"); // preserves the in-flight cursor + // Page appended exactly once. + expect(state.getTools().map((t) => t.name)).toEqual(["a", "b"]); + expect(state.getPagination().pageCount).toBe(2); + }); + + describe("connect auto-load (#1721)", () => { + it("loads page 1 on connect in paginated mode", async () => { + const spClient = new FakeInspectorClient({ + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new PagedToolsState(spClient); + spClient.queueToolPages({ tools: [tool("a")], nextCursor: "c1" }); + const changed = waitForChange(spState); + spClient.dispatchTypedEvent("connect"); + expect((await changed).map((t) => t.name)).toEqual(["a"]); + expect(spState.getPagination()).toEqual({ + nextCursor: "c1", + pageCount: 1, + }); + spState.destroy(); + }); + + it("does NOT load on connect when paginated mode is off", async () => { + client.setStatus("connected"); + client.queueToolPages({ tools: [tool("a")] }); + client.dispatchTypedEvent("connect"); + await Promise.resolve(); + expect(client.listTools).not.toHaveBeenCalled(); + expect(state.getTools()).toEqual([]); + }); + }); }); diff --git a/clients/web/src/test/core/react/useManagedPrompts.test.tsx b/clients/web/src/test/core/react/useManagedPrompts.test.tsx index ff40e29b..6d3e8e6c 100644 --- a/clients/web/src/test/core/react/useManagedPrompts.test.tsx +++ b/clients/web/src/test/core/react/useManagedPrompts.test.tsx @@ -138,5 +138,23 @@ describe("useManagedPrompts", () => { const { result } = renderHook(() => useManagedPrompts(client, null)); expect(result.current.listChanged).toBe(false); }); + + it("clearListChanged() acknowledges the indicator without fetching (#1721)", async () => { + const { result } = renderHook(() => useManagedPrompts(client, state)); + act(() => { + client.dispatchTypedEvent("promptsListChanged"); + }); + await waitFor(() => expect(result.current.listChanged).toBe(true)); + act(() => { + result.current.clearListChanged(); + }); + await waitFor(() => expect(result.current.listChanged).toBe(false)); + expect(client.listAllPrompts).not.toHaveBeenCalled(); + }); + + it("clearListChanged() is a no-op when state is null", () => { + const { result } = renderHook(() => useManagedPrompts(client, null)); + expect(() => result.current.clearListChanged()).not.toThrow(); + }); }); }); diff --git a/clients/web/src/test/core/react/useManagedResources.test.tsx b/clients/web/src/test/core/react/useManagedResources.test.tsx index ee94a5a6..f2f8791d 100644 --- a/clients/web/src/test/core/react/useManagedResources.test.tsx +++ b/clients/web/src/test/core/react/useManagedResources.test.tsx @@ -143,5 +143,23 @@ describe("useManagedResources", () => { const { result } = renderHook(() => useManagedResources(client, null)); expect(result.current.listChanged).toBe(false); }); + + it("clearListChanged() acknowledges the indicator without fetching (#1721)", async () => { + const { result } = renderHook(() => useManagedResources(client, state)); + act(() => { + client.dispatchTypedEvent("resourcesListChanged"); + }); + await waitFor(() => expect(result.current.listChanged).toBe(true)); + act(() => { + result.current.clearListChanged(); + }); + await waitFor(() => expect(result.current.listChanged).toBe(false)); + expect(client.listAllResources).not.toHaveBeenCalled(); + }); + + it("clearListChanged() is a no-op when state is null", () => { + const { result } = renderHook(() => useManagedResources(client, null)); + expect(() => result.current.clearListChanged()).not.toThrow(); + }); }); }); diff --git a/clients/web/src/test/core/react/useManagedTools.test.tsx b/clients/web/src/test/core/react/useManagedTools.test.tsx index a4fa7d25..8f90eae3 100644 --- a/clients/web/src/test/core/react/useManagedTools.test.tsx +++ b/clients/web/src/test/core/react/useManagedTools.test.tsx @@ -138,6 +138,26 @@ describe("useManagedTools", () => { expect(result.current.listChanged).toBe(false); }); + it("clearListChanged() acknowledges the indicator without fetching (#1721)", async () => { + const { result } = renderHook(() => useManagedTools(client, state)); + act(() => { + client.dispatchTypedEvent("toolsListChanged"); + }); + await waitFor(() => expect(result.current.listChanged).toBe(true)); + + act(() => { + result.current.clearListChanged(); + }); + await waitFor(() => expect(result.current.listChanged).toBe(false)); + // No list fetch happened — only the flag was cleared. + expect(client.listAllTools).not.toHaveBeenCalled(); + }); + + it("clearListChanged() is a no-op when state is null", () => { + const { result } = renderHook(() => useManagedTools(client, null)); + expect(() => result.current.clearListChanged()).not.toThrow(); + }); + it("keeps a change that fires during the awaited refresh (race regression)", async () => { const { result } = renderHook(() => useManagedTools(client, state)); diff --git a/clients/web/src/test/core/react/usePagedPrompts.test.tsx b/clients/web/src/test/core/react/usePagedPrompts.test.tsx index 63be072b..1a3bca4a 100644 --- a/clients/web/src/test/core/react/usePagedPrompts.test.tsx +++ b/clients/web/src/test/core/react/usePagedPrompts.test.tsx @@ -65,6 +65,20 @@ describe("usePagedPrompts", () => { }); }); + it("exposes nextCursor and pageCount from paginationChange", async () => { + const { result } = renderHook(() => usePagedPrompts(client, state)); + expect(result.current.nextCursor).toBeUndefined(); + expect(result.current.pageCount).toBe(0); + client.queuePromptPages({ prompts: [prompt("a")], nextCursor: "c1" }); + await act(async () => { + await result.current.loadPage(); + }); + await waitFor(() => { + expect(result.current.nextCursor).toBe("c1"); + expect(result.current.pageCount).toBe(1); + }); + }); + it("clear() proxies to the state", async () => { client.queuePromptPages({ prompts: [prompt("a")] }); await state.loadPage(); diff --git a/clients/web/src/test/core/react/usePagedResources.test.tsx b/clients/web/src/test/core/react/usePagedResources.test.tsx index f8542437..b5b6be44 100644 --- a/clients/web/src/test/core/react/usePagedResources.test.tsx +++ b/clients/web/src/test/core/react/usePagedResources.test.tsx @@ -68,6 +68,23 @@ describe("usePagedResources", () => { }); }); + it("exposes nextCursor and pageCount from paginationChange", async () => { + const { result } = renderHook(() => usePagedResources(client, state)); + expect(result.current.nextCursor).toBeUndefined(); + expect(result.current.pageCount).toBe(0); + client.queueResourcePages({ + resources: [resource("a://1")], + nextCursor: "c1", + }); + await act(async () => { + await result.current.loadPage(); + }); + await waitFor(() => { + expect(result.current.nextCursor).toBe("c1"); + expect(result.current.pageCount).toBe(1); + }); + }); + it("clear() proxies to the state", async () => { client.queueResourcePages({ resources: [resource("a://1")] }); await state.loadPage(); diff --git a/clients/web/src/test/core/react/usePagedTools.test.tsx b/clients/web/src/test/core/react/usePagedTools.test.tsx index acdfa4db..c5bc5f85 100644 --- a/clients/web/src/test/core/react/usePagedTools.test.tsx +++ b/clients/web/src/test/core/react/usePagedTools.test.tsx @@ -66,6 +66,28 @@ describe("usePagedTools", () => { }); }); + it("exposes nextCursor and pageCount from paginationChange", async () => { + const { result } = renderHook(() => usePagedTools(client, state)); + expect(result.current.nextCursor).toBeUndefined(); + expect(result.current.pageCount).toBe(0); + client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" }); + await act(async () => { + await result.current.loadPage(); + }); + await waitFor(() => { + expect(result.current.nextCursor).toBe("c1"); + expect(result.current.pageCount).toBe(1); + }); + }); + + it("seeds pagination from the state's initial snapshot", async () => { + client.queueToolPages({ tools: [tool("a")], nextCursor: "c1" }); + await state.loadPage(); + const { result } = renderHook(() => usePagedTools(client, state)); + expect(result.current.nextCursor).toBe("c1"); + expect(result.current.pageCount).toBe(1); + }); + it("clear() proxies to the state", async () => { client.queueToolPages({ tools: [tool("a")] }); await state.loadPage(); diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index 8e8a410d..0ee49df7 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -431,6 +431,7 @@ describe("useServers", () => { taskTtl: 30000, maxFetchRequests: 1000, autoRefreshOnListChanged: false, + paginatedLists: false, roots: [], }); }); @@ -518,6 +519,7 @@ describe("useServers", () => { maxFetchRequests: 1000, // Absent autoRefreshOnListChanged on disk reads back as false. autoRefreshOnListChanged: false, + paginatedLists: false, // Absent roots on disk reads back as an empty list for the form. roots: [], }); diff --git a/clients/web/src/test/fixtures/pagination.ts b/clients/web/src/test/fixtures/pagination.ts new file mode 100644 index 00000000..401cb97d --- /dev/null +++ b/clients/web/src/test/fixtures/pagination.ts @@ -0,0 +1,14 @@ +import type { ListPaginationControlsProps } from "../../components/elements/ListPaginationControls/ListPaginationControls"; + +/** + * A no-op, all-pages-mode pagination model for tests and stories that don't + * exercise paginated-mode fetching but must satisfy the required `pagination` + * prop threaded through the list screens/controls (#1721). + */ +export const noopPagination: ListPaginationControlsProps = { + paginated: false, + onPaginatedChange: () => {}, + canLoadMore: false, + loadedPages: 0, + onLoadMore: () => {}, +}; diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 571bbadd..14bcf9a5 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -1147,6 +1147,70 @@ describe("InspectorClient", () => { expect(page4.nextCursor).toBeUndefined(); expect(page4.tools[0]?.name).toBe("tool_10"); }); + + it("listAllTools aggregates every page via the SDK's cache-aware verb (#1721)", async () => { + // Disconnect and create a new server with pagination + await client!.disconnect(); + if (server) { + await server.stop(); + } + + // 10 tools, page size 3 → 4 wire pages the SDK walks internally. + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: createNumberedTools(10), + maxPageSize: { tools: 3 }, + }); + await server.start(); + + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + await client.connect(); + + // One call returns the fully-aggregated list (no per-page cursor + // surfaced), unlike the single-page listTools() above. `cacheMode: + // "refresh"` forces a wire fetch — a no-op here (no ttlMs hints) but the + // path the managed refresh uses. + const all = await client.listAllTools({ cacheMode: "refresh" }); + expect(all.tools.map((t) => t.name)).toEqual( + Array.from({ length: 10 }, (_, i) => `tool_${i + 1}`), + ); + }); + + it("listAllResources / listAllPrompts / listAllResourceTemplates aggregate too (#1721)", async () => { + await client!.disconnect(); + if (server) { + await server.stop(); + } + server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: createNumberedTools(2), + }); + await server.start(); + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { + environment: { transport: createTransportNode }, + clientIdentity: { name: "test", version: "1.0.0" }, + }, + ); + await client.connect(); + + // Exercises the remaining three aggregate methods (empty is fine — the + // point is the SDK verb runs and returns a well-formed shape). Default + // cacheMode ('use') for these. + const resources = await client.listAllResources(); + const prompts = await client.listAllPrompts({ metadata: { k: "v" } }); + const templates = await client.listAllResourceTemplates(); + expect(Array.isArray(resources.resources)).toBe(true); + expect(Array.isArray(prompts.prompts)).toBe(true); + expect(Array.isArray(templates.resourceTemplates)).toBe(true); + }); }); describe("Default metadata (server-wide _meta)", () => { @@ -5541,6 +5605,12 @@ describe("InspectorClient", () => { await expectThrow(() => c.listResourceTemplates()); await expectThrow(() => c.listPrompts()); + // aggregate (all-page, cache-aware) listing (#1721) + await expectThrow(() => c.listAllTools()); + await expectThrow(() => c.listAllResources()); + await expectThrow(() => c.listAllResourceTemplates()); + await expectThrow(() => c.listAllPrompts()); + // single-item fetch await expectThrow(() => c.callTool({ name: "x" } as unknown as Tool, {}), diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index bcffb3e8..afb19929 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -444,6 +444,14 @@ describe("server.ts supplemental coverage", () => { expect((await res.json()).error).toMatch(/autoRefreshOnListChanged/); }); + it("rejects a non-boolean paginatedLists", async () => { + const res = await postSettings({ + ...base, + paginatedLists: "yes", + }); + expect((await res.json()).error).toMatch(/paginatedLists/); + }); + it("rejects a negative maxFetchRequests", async () => { const res = await postSettings({ ...base, maxFetchRequests: -2 }); expect((await res.json()).error).toMatch(/maxFetchRequests/); @@ -476,6 +484,7 @@ describe("server.ts supplemental coverage", () => { cwd: "/tmp", taskTtl: 1000, autoRefreshOnListChanged: true, + paginatedLists: true, maxFetchRequests: 5, protocolEra: "modern", oauthClientId: "cid", diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index b4d2149f..3934b5f0 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -7,6 +7,7 @@ import { vi } from "vitest"; import type { + CacheMode, ClientCapabilities, DiscoverResult, Implementation, @@ -40,6 +41,24 @@ type ListResult = { [K in TKey]: TItem[]; } & { nextCursor?: string }; +/** + * Drain the entire queue of pre-canned pages for a list key and return the + * flattened items — the aggregate `listAll*` mocks consume every queued page in + * one call, mirroring the SDK's all-page walk (the single-page `list*` mocks + * shift one page per call instead). + */ +function drainPages( + pages: Array>, + key: TKey, +): TItem[] { + const items: TItem[] = []; + while (pages.length > 0) { + const page = pages.shift(); + if (page) items.push(...page[key]); + } + return items; +} + export interface FakeInspectorClientOptions { status?: ConnectionStatus; capabilities?: ServerCapabilities; @@ -93,6 +112,40 @@ export class FakeInspectorClient async () => this.taskPages.shift() ?? { tasks: [] }, ); + // Aggregate variants used by the managed state stores on refresh: drain ALL + // queued pages (mimicking the SDK's all-page walk) and return the flattened + // list. The `options` (incl. `cacheMode`) is recorded by the `vi.fn` so tests + // can assert a user/auto refresh forwarded `cacheMode: "refresh"`. + listAllTools = vi.fn( + async (_options?: { + cacheMode?: CacheMode; + metadata?: Record; + }) => ({ tools: drainPages(this.toolPages, "tools") }), + ); + listAllPrompts = vi.fn( + async (_options?: { + cacheMode?: CacheMode; + metadata?: Record; + }) => ({ prompts: drainPages(this.promptPages, "prompts") }), + ); + listAllResources = vi.fn( + async (_options?: { + cacheMode?: CacheMode; + metadata?: Record; + }) => ({ resources: drainPages(this.resourcePages, "resources") }), + ); + listAllResourceTemplates = vi.fn( + async (_options?: { + cacheMode?: CacheMode; + metadata?: Record; + }) => ({ + resourceTemplates: drainPages( + this.resourceTemplatePages, + "resourceTemplates", + ), + }), + ); + callTool = vi.fn( async ( tool: Tool, diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 2b48cc06..932d73b1 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -84,6 +84,8 @@ import type { import type { Transport } from "@modelcontextprotocol/client"; import type { RequestOptions, + CacheableRequestOptions, + CacheMode, ProgressCallback, VersionNegotiationOptions, ProtocolEra, @@ -676,6 +678,38 @@ export class InspectorClient extends InspectorClientEventTarget { return opts; } + /** + * {@link getRequestOptions} plus the per-call `cacheMode` for the SDK's + * cacheable verbs (the high-level `client.listTools()` / `listPrompts()` / + * `listResources()` / `listResourceTemplates()` used by the `listAll*` + * aggregate methods below). `cacheMode` is only honored by those wrappers — + * the single-page `client.request` path ignores it — so it lives here rather + * than in `getRequestOptions`. Omitted when unset so the SDK default + * (`'use'`) applies. + */ + private getCacheableRequestOptions( + cacheMode?: CacheMode, + ): CacheableRequestOptions { + const opts: CacheableRequestOptions = this.getRequestOptions(); + if (cacheMode !== undefined) { + opts.cacheMode = cacheMode; + } + return opts; + } + + /** + * Build the `params` for the aggregate `listAll*` verbs: merge call metadata + * with `defaultMetadata` and wrap as `{ _meta }`, or `undefined` when empty + * (so the SDK skips an empty `_meta`). Shared by all four `listAll*` methods + * so the merge/omit branch is defined once. + */ + private aggregateListParams( + metadata?: Record, + ): { _meta: Record } | undefined { + const effectiveMeta = this.mergeMeta(metadata); + return effectiveMeta ? { _meta: effectiveMeta } : undefined; + } + private isHttpOAuthConfig(): boolean { const serverType = getServerTypeFromConfig(this.transportConfig); return ( @@ -2098,6 +2132,34 @@ export class InspectorClient extends InspectorClientEventTarget { return { tools, nextCursor: response.nextCursor }; } + /** + * Aggregate ALL pages of `tools/list` via the SDK's high-level + * `client.listTools()` — the cache-aware verb. Unlike the single-page + * {@link listTools} (raw `client.request`, for pagination debugging), this is + * the path the managed tool list uses on refresh: the SDK walks every page, + * applies the SEP-2243 `x-mcp-header` exclusion, and consults/writes its + * response cache. `cacheMode` selects the disposition (`'use'` serves a + * still-fresh cached list without a round trip; `'refresh'` always fetches + * and re-stores; `'bypass'` fetches without touching the cache) — only + * meaningful on modern servers that send `ttlMs` hints; a no-op on legacy + * (nothing is cached, so every call hits the wire regardless). + */ + async listAllTools(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ tools: Tool[] }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const response = await this.invokeMcpClient(() => + this.client!.listTools( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ), + ); + return { tools: [...response.tools] }; + } + /** * Call a tool. Caller must provide the Tool (e.g. from a state manager). * @param tool The tool to call (use tool.name for the request) @@ -2886,6 +2948,28 @@ export class InspectorClient extends InspectorClientEventTarget { }; } + /** + * Aggregate ALL pages of `resources/list` via the SDK's high-level + * cache-aware `client.listResources()`. See {@link listAllTools} for the + * `cacheMode` semantics; this is the path the managed resource list uses on + * refresh. + */ + async listAllResources(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ resources: Resource[] }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const response = await this.invokeMcpClient(() => + this.client!.listResources( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ), + ); + return { resources: [...response.resources] }; + } + /** * Read a resource by URI * @param uri Resource URI @@ -3020,6 +3104,30 @@ export class InspectorClient extends InspectorClientEventTarget { }; } + /** + * Aggregate ALL pages of `resources/templates/list` via the SDK's high-level + * cache-aware `client.listResourceTemplates()`. See {@link listAllTools} for + * the `cacheMode` semantics; this is the path the managed resource-template + * list uses on refresh. + */ + async listAllResourceTemplates(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ resourceTemplates: ResourceTemplate[] }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const response = await this.invokeMcpClient( + () => + this.client!.listResourceTemplates( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ), + { method: "resources/templates/list" }, + ); + return { resourceTemplates: [...response.resourceTemplates] }; + } + /** * List available prompts with pagination support * @param cursor Optional cursor for pagination @@ -3051,6 +3159,28 @@ export class InspectorClient extends InspectorClientEventTarget { }; } + /** + * Aggregate ALL pages of `prompts/list` via the SDK's high-level + * cache-aware `client.listPrompts()`. See {@link listAllTools} for the + * `cacheMode` semantics; this is the path the managed prompt list uses on + * refresh. + */ + async listAllPrompts(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ prompts: Prompt[] }> { + if (!this.client) { + throw new Error("Client is not connected"); + } + const response = await this.invokeMcpClient(() => + this.client!.listPrompts( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ), + ); + return { prompts: [...response.prompts] }; + } + /** * Get a prompt by name * @param name Prompt name diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 739b0b9b..98ea0ba0 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -21,6 +21,7 @@ import type { ToolCallInvocation, } from "./types.js"; import type { + CacheMode, ClientCapabilities, DiscoverResult, Implementation, @@ -89,6 +90,29 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { cursor?: string, ): Promise<{ tasks: Task[]; nextCursor?: string }>; + // Aggregate (all-page) list methods used by the managed state stores on + // refresh. Unlike the single-page methods above, these route through the + // SDK's cache-aware high-level verbs so `cacheMode` ('use' | 'refresh' | + // 'bypass') is honored — a manual refresh forces a cache-bypassing round trip + // on modern servers that send `ttlMs` hints. Options are optional; omitting + // `cacheMode` uses the SDK default ('use'). + listAllTools(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ tools: Tool[] }>; + listAllPrompts(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ prompts: Prompt[] }>; + listAllResources(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ resources: Resource[] }>; + listAllResourceTemplates(options?: { + cacheMode?: CacheMode; + metadata?: Record; + }): Promise<{ resourceTemplates: ResourceTemplate[] }>; + // Invocation methods used by paged result panels callTool( tool: Tool, diff --git a/core/mcp/node/servers.ts b/core/mcp/node/servers.ts index 8bda9cc2..532f2976 100644 --- a/core/mcp/node/servers.ts +++ b/core/mcp/node/servers.ts @@ -56,6 +56,7 @@ export function headersToServerSettings( taskTtl: DEFAULT_TASK_TTL_MS, maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS, autoRefreshOnListChanged: false, + paginatedLists: false, roots: [], }; } diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index c9d20382..ea631084 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1393,6 +1393,16 @@ export function createRemoteApp( error: "settings.autoRefreshOnListChanged must be a boolean", }; } + // Optional on the wire; boolean when present, else defaults to false below. + if ( + obj.paginatedLists !== undefined && + typeof obj.paginatedLists !== "boolean" + ) { + return { + ok: false, + error: "settings.paginatedLists must be a boolean", + }; + } // maxFetchRequests is optional on the wire (older clients won't send it); // when present it must be a non-negative number (0 = unlimited), otherwise // it defaults below. @@ -1477,6 +1487,8 @@ export function createRemoteApp( // Absent → false, matching the read side. The omit-on-false logic lives // in inspectorSettingsToStoredFields, so a false value writes nothing. autoRefreshOnListChanged: obj.autoRefreshOnListChanged === true, + // Absent → false, matching the read side (omit-on-false on the write side). + paginatedLists: obj.paginatedLists === true, // Absent → product default, matching the read side. The default is the // omit-sentinel in inspectorSettingsToStoredFields, so a client that // didn't send one writes no spurious maxFetchRequests to disk. diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 7e70c63f..278c555d 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -120,6 +120,7 @@ type StoredInspectorFields = Pick< | "requestTimeout" | "taskTtl" | "autoRefreshOnListChanged" + | "paginatedLists" | "maxFetchRequests" | "oauth" | "roots" @@ -186,6 +187,7 @@ export function storedFieldsToInspectorSettings( stored.requestTimeout !== undefined || stored.taskTtl !== undefined || stored.autoRefreshOnListChanged !== undefined || + stored.paginatedLists !== undefined || stored.maxFetchRequests !== undefined || stored.oauth !== undefined || stored.roots !== undefined || @@ -208,6 +210,7 @@ export function storedFieldsToInspectorSettings( // default so the form shows it and "Run as task" has a value to send. taskTtl: stored.taskTtl ?? DEFAULT_TASK_TTL_MS, autoRefreshOnListChanged: stored.autoRefreshOnListChanged ?? false, + paginatedLists: stored.paginatedLists ?? false, // Concrete default like taskTtl (not a 0-sentinel): the form needs a value // to render and the log state needs one to size its buffer. An absent // on-disk field reads back as the default, which the write side then omits. @@ -293,6 +296,11 @@ export function inspectorSettingsToStoredFields( out.autoRefreshOnListChanged = true; } + // Persist only when enabled — absent reads back as false (above). + if (settings.paginatedLists) { + out.paginatedLists = true; + } + // Persist only when it differs from the default era. Absent reads back as // DEFAULT_PROTOCOL_ERA, so writing the default would inject the field into // hand-edited files that never had it and break byte-stable round-trips. @@ -359,6 +367,7 @@ const INSPECTOR_FIELD_KEY_MAP = { requestTimeout: true, taskTtl: true, autoRefreshOnListChanged: true, + paginatedLists: true, maxFetchRequests: true, oauth: true, roots: true, diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts index 62e9298e..080fd39b 100644 --- a/core/mcp/state/fetchRequestLogState.ts +++ b/core/mcp/state/fetchRequestLogState.ts @@ -239,9 +239,7 @@ export class FetchRequestLogState extends TypedEventTarget 0 - ? merged.slice(-this.maxFetchRequests) - : merged; + this.maxFetchRequests > 0 ? merged.slice(-this.maxFetchRequests) : merged; this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); } diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index 0cf67137..4f6160e1 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -13,12 +13,13 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js"; -import type { ServerCapabilities } from "@modelcontextprotocol/client"; +import type { + CacheMode, + ServerCapabilities, +} from "@modelcontextprotocol/client"; import { isTerminalStatus } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; -const MAX_PAGES = 100; - /** * Default delay (ms) for debouncing `list_changed` notifications. Servers * (e.g. the everything server) can emit a rapid burst; debouncing collapses the @@ -33,12 +34,6 @@ export interface ManagedListEventMap { listChangedChange: boolean; } -/** A single page of a list result, normalized across the SDK list methods. */ -export interface ListPage { - items: T[]; - nextCursor?: string; -} - export interface ManagedListConfig { /** The `*Change` event this manager dispatches (e.g. "toolsChange"). */ changeEvent: keyof M; @@ -46,14 +41,16 @@ export interface ManagedListConfig { listChangedEvent: keyof InspectorClientEventMap; /** Server capability gating the list call (empty list when absent). */ capabilityKey: keyof ServerCapabilities; - /** Human label used in the pagination-cap error message. */ - itemLabel: string; - /** Fetch a single page from the client. */ - fetchPage: ( + /** + * Fetch the complete (all-page) list from the client. Delegates page walking + * to the SDK's cache-aware high-level verb (via the client's `listAll*` + * methods), which also lets `cacheMode` select the cache disposition. + */ + fetchAll: ( client: InspectorClientProtocol, - cursor: string | undefined, + cacheMode: CacheMode | undefined, metadata: Record | undefined, - ) => Promise>; + ) => Promise; /** * Whether this list drives a list-changed indicator. When true, a * `list_changed` in non-auto-refresh mode lights the indicator blindly (no @@ -62,6 +59,15 @@ export interface ManagedListConfig { * pulled via the screen's Refresh instead. */ supportsIndicator: boolean; + /** + * Whether this list has a paged (paginated) counterpart that drives the + * display when `paginatedLists` is on. Only such lists defer their + * connect-time / `list_changed` aggregate walk in paginated mode (tools, + * prompts, resources). Lists with no paged counterpart (resource templates) + * set this `false` so they still aggregate on connect regardless of the + * setting — otherwise they'd never load in paginated mode (#1721). + */ + deferWhenPaginated: boolean; /** Debounce delay (ms) for `list_changed` bursts. */ debounceMs: number; } @@ -94,6 +100,18 @@ export abstract class ManagedListState< this.client = client; this.config = config; const onConnect = (): void => { + // In paginated mode the aggregate list is not the display source (the + // paged state drives the sidebar), so skip the connect-time all-page + // walk — the whole point of the setting is to avoid pulling every page + // for servers with very large lists (#1721). Switching back to + // all-pages mode triggers a refresh from the UI. Only lists with a paged + // counterpart defer here; resource templates (none) still aggregate. + if ( + this.config.deferWhenPaginated && + this.client?.getServerSettings()?.paginatedLists + ) { + return; + } void this.refresh(); }; const onListChanged = (): void => { @@ -155,10 +173,22 @@ export abstract class ManagedListState< try { do { this.runQueued = false; - // Read the setting at fire time so a `setServerSettings` toggle that + // Read the settings at fire time so a `setServerSettings` toggle that // lands mid-burst is honored on the settled action. - if (this.client?.getServerSettings()?.autoRefreshOnListChanged) { - await this.refresh(); + const settings = this.client?.getServerSettings(); + // In paginated mode the aggregate list is not the display source for + // lists with a paged counterpart, so never auto-aggregate on + // `list_changed` there — only light the indicator so the user can pull + // page 1 fresh via Refresh (#1721). This wins over + // `autoRefreshOnListChanged`, which would otherwise pull every page. + // Lists with no paged counterpart (resource templates) still aggregate. + const skipAggregate = + this.config.deferWhenPaginated && settings?.paginatedLists; + if (!skipAggregate && settings?.autoRefreshOnListChanged) { + // A `list_changed` means the prior list is stale, so bypass any + // cached entry (`cacheMode: "refresh"`) and re-store the fresh + // aggregate. + await this.refresh(undefined, "refresh"); } else if (this.config.supportsIndicator) { this.setListChanged(true); } @@ -210,8 +240,17 @@ export abstract class ManagedListState< this._metadata = metadata; } - async refresh(metadata?: Record): Promise { - const next = await this.fetchAll(metadata); + /** + * Refresh the full list. `cacheMode` selects the SDK cache disposition for + * this fetch: `undefined` (the connect-time load) uses the default `'use'`; + * a user-initiated or auto refresh passes `'refresh'` to force a + * cache-bypassing round trip and re-store the fresh aggregate. + */ + async refresh( + metadata?: Record, + cacheMode?: CacheMode, + ): Promise { + const next = await this.fetchItems(metadata, cacheMode); // `null` means not connected — leave the current list untouched. if (next === null) return this.getItems(); this.applyItems(next); @@ -219,38 +258,21 @@ export abstract class ManagedListState< } /** - * Fetch all pages, then `applyItems` commits them (see `refresh`). Returns - * `null` when not connected, or - * `[]` when the server doesn't advertise the gating capability (calling the - * list method there returns -32601 "Method not found", which would spam the - * console; empty list is the right semantics). + * Fetch the complete list (all pages, via the SDK's cache-aware verb), then + * `applyItems` commits it (see `refresh`). Returns `null` when not connected, + * or `[]` when the server doesn't advertise the gating capability (calling + * the list method there returns -32601 "Method not found", which would spam + * the console; empty list is the right semantics). */ - private async fetchAll( - metadata?: Record, + private async fetchItems( + metadata: Record | undefined, + cacheMode: CacheMode | undefined, ): Promise { const client = this.client; if (!client || client.getStatus() !== "connected") return null; if (!client.getCapabilities()?.[this.config.capabilityKey]) return []; const effectiveMetadata = metadata ?? this._metadata; - let items: T[] = []; - let cursor: string | undefined; - let pageCount = 0; - do { - const page = await this.config.fetchPage( - client, - cursor, - effectiveMetadata, - ); - items = cursor ? [...items, ...page.items] : page.items; - cursor = page.nextCursor; - pageCount++; - if (pageCount >= MAX_PAGES) { - throw new Error( - `Maximum pagination limit (${MAX_PAGES} pages) reached while listing ${this.config.itemLabel}`, - ); - } - } while (cursor); - return items; + return this.config.fetchAll(client, cacheMode, effectiveMetadata); } /** Commit a fetched list as the current one and notify subscribers. */ diff --git a/core/mcp/state/managedPromptsState.ts b/core/mcp/state/managedPromptsState.ts index a986a195..a12fcd4a 100644 --- a/core/mcp/state/managedPromptsState.ts +++ b/core/mcp/state/managedPromptsState.ts @@ -33,12 +33,12 @@ export class ManagedPromptsState extends ManagedListState< changeEvent: "promptsChange", listChangedEvent: "promptsListChanged", capabilityKey: "prompts", - itemLabel: "prompts", + deferWhenPaginated: true, supportsIndicator: true, debounceMs, - fetchPage: async (c, cursor, metadata) => { - const result = await c.listPrompts(cursor, metadata); - return { items: result.prompts, nextCursor: result.nextCursor }; + fetchAll: async (c, cacheMode, metadata) => { + const result = await c.listAllPrompts({ cacheMode, metadata }); + return result.prompts; }, }); } diff --git a/core/mcp/state/managedResourceTemplatesState.ts b/core/mcp/state/managedResourceTemplatesState.ts index b7d0e003..aceedd4f 100644 --- a/core/mcp/state/managedResourceTemplatesState.ts +++ b/core/mcp/state/managedResourceTemplatesState.ts @@ -39,15 +39,17 @@ export class ManagedResourceTemplatesState extends ManagedListState< listChangedEvent: "resourceTemplatesListChanged", // Templates are gated on the broader `resources` capability. capabilityKey: "resources", - itemLabel: "resource templates", + // No paged counterpart (templates aren't paginated in the UI), so they + // must still aggregate on connect even in paginated mode (#1721). + deferWhenPaginated: false, supportsIndicator: false, debounceMs, - fetchPage: async (c, cursor, metadata) => { - const result = await c.listResourceTemplates(cursor, metadata); - return { - items: result.resourceTemplates, - nextCursor: result.nextCursor, - }; + fetchAll: async (c, cacheMode, metadata) => { + const result = await c.listAllResourceTemplates({ + cacheMode, + metadata, + }); + return result.resourceTemplates; }, }); } diff --git a/core/mcp/state/managedResourcesState.ts b/core/mcp/state/managedResourcesState.ts index f8865e81..f00076e4 100644 --- a/core/mcp/state/managedResourcesState.ts +++ b/core/mcp/state/managedResourcesState.ts @@ -33,12 +33,12 @@ export class ManagedResourcesState extends ManagedListState< changeEvent: "resourcesChange", listChangedEvent: "resourcesListChanged", capabilityKey: "resources", - itemLabel: "resources", + deferWhenPaginated: true, supportsIndicator: true, debounceMs, - fetchPage: async (c, cursor, metadata) => { - const result = await c.listResources(cursor, metadata); - return { items: result.resources, nextCursor: result.nextCursor }; + fetchAll: async (c, cacheMode, metadata) => { + const result = await c.listAllResources({ cacheMode, metadata }); + return result.resources; }, }); } diff --git a/core/mcp/state/managedToolsState.ts b/core/mcp/state/managedToolsState.ts index a9917622..03a87d59 100644 --- a/core/mcp/state/managedToolsState.ts +++ b/core/mcp/state/managedToolsState.ts @@ -33,12 +33,12 @@ export class ManagedToolsState extends ManagedListState< changeEvent: "toolsChange", listChangedEvent: "toolsListChanged", capabilityKey: "tools", - itemLabel: "tools", + deferWhenPaginated: true, supportsIndicator: true, debounceMs, - fetchPage: async (c, cursor, metadata) => { - const result = await c.listTools(cursor, metadata); - return { items: result.tools, nextCursor: result.nextCursor }; + fetchAll: async (c, cacheMode, metadata) => { + const result = await c.listAllTools({ cacheMode, metadata }); + return result.tools; }, }); } diff --git a/core/mcp/state/messageLogState.ts b/core/mcp/state/messageLogState.ts index 2cc6de7b..9355741e 100644 --- a/core/mcp/state/messageLogState.ts +++ b/core/mcp/state/messageLogState.ts @@ -130,9 +130,7 @@ export class MessageLogState extends TypedEventTarget { */ clearMessages(predicate?: (entry: MessageEntry) => boolean): void { const before = this.messages.length; - this.messages = predicate - ? this.messages.filter((m) => !predicate(m)) - : []; + this.messages = predicate ? this.messages.filter((m) => !predicate(m)) : []; // Drop pending-request bookkeeping for any entry that was just removed. // Without this, a later response matching an evicted request id would // mutate a Map-only entry that is no longer reachable from `messages`, diff --git a/core/mcp/state/pagedPromptsState.ts b/core/mcp/state/pagedPromptsState.ts index fd437e2b..ed649711 100644 --- a/core/mcp/state/pagedPromptsState.ts +++ b/core/mcp/state/pagedPromptsState.ts @@ -1,10 +1,12 @@ /** - * PagedPromptsState: holds an aggregated list of prompts loaded via loadPage(cursor). - * Does not load on connect; caller drives loading. Clears on disconnect. + * PagedPromptsState: holds the prompts accumulated so far, loaded one page at a + * time via loadPage(cursor). Backs paginated mode (`paginatedLists`, #1721): + * auto-loads page 1 on connect when the setting is on, and tracks the server's + * `nextCursor` + a running page count as observable state. Clears on disconnect. * * Intentionally does NOT subscribe to `promptsListChanged`: cursors are tied * to the server's prior list, so a list change mid-pagination would invalidate - * them. The caller decides how to react. + * them. The caller pulls page 1 again via Refresh instead. * * Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the * concrete `InspectorClient` since the runtime class is not yet ported. @@ -14,9 +16,11 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js"; import type { Prompt } from "@modelcontextprotocol/client"; import { isTerminalStatus } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; +import type { PagePaginationState } from "./pagedToolsState.js"; export interface PagedPromptsStateEventMap { promptsChange: Prompt[]; + paginationChange: PagePaginationState; } export interface LoadPageResult { @@ -26,21 +30,32 @@ export interface LoadPageResult { export class PagedPromptsState extends TypedEventTarget { private prompts: Prompt[] = []; + private nextCursor: string | undefined = undefined; + private pageCount = 0; + // Double-click guard: a load in flight makes the next `loadPage` a no-op so + // the same page can't be appended twice (#1721). + private loading = false; private client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; constructor(client: InspectorClientProtocol) { super(); this.client = client; - const onStatusChange = (): void => { - if (isTerminalStatus(this.client?.getStatus())) { - this.prompts = []; - this.dispatchTypedEvent("promptsChange", []); + const onConnect = (): void => { + if (this.client?.getServerSettings()?.paginatedLists) { + void this.loadPage(undefined); } }; + const onStatusChange = (): void => { + if (isTerminalStatus(this.client?.getStatus())) { + this.reset(); + } + }; + this.client.addEventListener("connect", onConnect); this.client.addEventListener("statusChange", onStatusChange); this.unsubscribe = () => { if (this.client) { + this.client.removeEventListener("connect", onConnect); this.client.removeEventListener("statusChange", onStatusChange); } this.client = null; @@ -51,9 +66,20 @@ export class PagedPromptsState extends TypedEventTarget { private resources: Resource[] = []; + private nextCursor: string | undefined = undefined; + private pageCount = 0; + // Double-click guard: a load in flight makes the next `loadPage` a no-op so + // the same page can't be appended twice (#1721). + private loading = false; private client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; constructor(client: InspectorClientProtocol) { super(); this.client = client; - const onStatusChange = (): void => { - if (isTerminalStatus(this.client?.getStatus())) { - this.resources = []; - this.dispatchTypedEvent("resourcesChange", []); + const onConnect = (): void => { + if (this.client?.getServerSettings()?.paginatedLists) { + void this.loadPage(undefined); } }; + const onStatusChange = (): void => { + if (isTerminalStatus(this.client?.getStatus())) { + this.reset(); + } + }; + this.client.addEventListener("connect", onConnect); this.client.addEventListener("statusChange", onStatusChange); this.unsubscribe = () => { if (this.client) { + this.client.removeEventListener("connect", onConnect); this.client.removeEventListener("statusChange", onStatusChange); } this.client = null; @@ -51,9 +67,20 @@ export class PagedResourcesState extends TypedEventTarget { private tools: Tool[] = []; + private nextCursor: string | undefined = undefined; + private pageCount = 0; + // Guards against a concurrent load (e.g. a fast double-click on "Load next + // page"): both calls would otherwise read the same cursor and append the + // same page twice. A load in flight makes the next `loadPage` a no-op (#1721). + private loading = false; private client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; constructor(client: InspectorClientProtocol) { super(); this.client = client; - const onStatusChange = (): void => { - if (isTerminalStatus(this.client?.getStatus())) { - this.tools = []; - this.dispatchTypedEvent("toolsChange", []); + const onConnect = (): void => { + // Auto-load page 1 only in paginated mode — otherwise the managed + // (aggregate) state is the display source and this stays idle (#1721). + if (this.client?.getServerSettings()?.paginatedLists) { + void this.loadPage(undefined); } }; + const onStatusChange = (): void => { + if (isTerminalStatus(this.client?.getStatus())) { + this.reset(); + } + }; + this.client.addEventListener("connect", onConnect); this.client.addEventListener("statusChange", onStatusChange); this.unsubscribe = () => { if (this.client) { + this.client.removeEventListener("connect", onConnect); this.client.removeEventListener("statusChange", onStatusChange); } this.client = null; @@ -52,9 +78,21 @@ export class PagedToolsState extends TypedEventTarget { return [...this.tools]; } + getPagination(): PagePaginationState { + return { nextCursor: this.nextCursor, pageCount: this.pageCount }; + } + + /** Clear the accumulated list and pagination progress. */ clear(): void { + this.reset(); + } + + private reset(): void { this.tools = []; + this.nextCursor = undefined; + this.pageCount = 0; this.dispatchTypedEvent("toolsChange", this.tools); + this.dispatchTypedEvent("paginationChange", this.getPagination()); } async loadPage(cursor?: string): Promise { @@ -62,18 +100,35 @@ export class PagedToolsState extends TypedEventTarget { if (!c || c.getStatus() !== "connected") { return { tools: [], nextCursor: undefined }; } - const result = await c.listTools(cursor, undefined); - this.tools = - cursor === undefined - ? [...result.tools] - : [...this.tools, ...result.tools]; - this.dispatchTypedEvent("toolsChange", this.tools); - return { tools: result.tools, nextCursor: result.nextCursor }; + // Drop a concurrent load (double-click guard) — the in-flight one owns the + // current cursor; return it so callers don't misread this as "the end". + if (this.loading) { + return { tools: [], nextCursor: this.nextCursor }; + } + this.loading = true; + try { + const result = await c.listTools(cursor, undefined); + // An undefined cursor is page 1 — replace the list and reset the count; + // a cursor appends the next page. + this.tools = + cursor === undefined + ? [...result.tools] + : [...this.tools, ...result.tools]; + this.pageCount = cursor === undefined ? 1 : this.pageCount + 1; + this.nextCursor = result.nextCursor; + this.dispatchTypedEvent("toolsChange", this.tools); + this.dispatchTypedEvent("paginationChange", this.getPagination()); + return { tools: result.tools, nextCursor: result.nextCursor }; + } finally { + this.loading = false; + } } destroy(): void { this.unsubscribe?.(); this.unsubscribe = null; this.tools = []; + this.nextCursor = undefined; + this.pageCount = 0; } } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 444f4f34..cefc10bd 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -123,6 +123,14 @@ export type StoredMCPServer = MCPServerConfig & { * false (the default). (#1402) */ autoRefreshOnListChanged?: boolean; + /** + * When true, the tools/resources/prompts lists are fetched one page at a time + * (a manual "Load next page" control surfaces the server's `nextCursor`) + * instead of auto-aggregating every page on load. A defensive default for + * servers with very large lists. Inspector-specific. Omitted on disk when + * false (the default). (#1721) + */ + paginatedLists?: boolean; /** * Maximum number of HTTP fetch requests retained in the Network log for this * server (oldest rotate out past the cap). Inspector-specific. Omitted on @@ -545,6 +553,12 @@ export interface InspectorServerSettings { * and the user pulls the new list via Refresh. (#1402) */ autoRefreshOnListChanged?: boolean; + /** + * When true, the tools/resources/prompts lists fetch one page at a time (a + * manual "Load next page" control) instead of auto-aggregating all pages. + * Default false. Server-wide; the per-list sidebar toggle edits this. (#1721) + */ + paginatedLists?: boolean; /** * Maximum number of HTTP fetch requests retained in the Network log for this * server. When exceeded, the oldest entries rotate out (and any deferred diff --git a/core/react/useManagedPrompts.ts b/core/react/useManagedPrompts.ts index cacb8bd8..db9b94e1 100644 --- a/core/react/useManagedPrompts.ts +++ b/core/react/useManagedPrompts.ts @@ -12,6 +12,8 @@ export interface UseManagedPromptsResult { /** True when a `prompts/list_changed` arrived since the last user refresh. */ listChanged: boolean; refresh: () => Promise; + /** Acknowledge the list-changed indicator without fetching (#1721). */ + clearListChanged: () => void; } /** @@ -71,10 +73,17 @@ export function useManagedPrompts( // afterward would wipe that genuinely-new signal and the user would miss // it. Clearing up front acknowledges only the change in hand. managedPromptsState.clearListChanged(); - const next = await managedPromptsState.refresh(); + // A user-initiated refresh forces a cache-bypassing round trip + // (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't + // return stale — and re-stores the fresh aggregate. + const next = await managedPromptsState.refresh(undefined, "refresh"); setPrompts(next); return next; }, [client, managedPromptsState]); - return { prompts, listChanged, refresh }; + const clearListChanged = useCallback(() => { + managedPromptsState?.clearListChanged(); + }, [managedPromptsState]); + + return { prompts, listChanged, refresh, clearListChanged }; } diff --git a/core/react/useManagedResourceTemplates.ts b/core/react/useManagedResourceTemplates.ts index 1f09d09a..3851769e 100644 --- a/core/react/useManagedResourceTemplates.ts +++ b/core/react/useManagedResourceTemplates.ts @@ -52,7 +52,13 @@ export function useManagedResourceTemplates( const refresh = useCallback(async (): Promise => { if (!managedResourceTemplatesState || !client) return []; - const next = await managedResourceTemplatesState.refresh(); + // A user-initiated refresh forces a cache-bypassing round trip + // (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't + // return stale — and re-stores the fresh aggregate. + const next = await managedResourceTemplatesState.refresh( + undefined, + "refresh", + ); setResourceTemplates(next); return next; }, [client, managedResourceTemplatesState]); diff --git a/core/react/useManagedResources.ts b/core/react/useManagedResources.ts index 9e8d2e70..8fe03172 100644 --- a/core/react/useManagedResources.ts +++ b/core/react/useManagedResources.ts @@ -14,6 +14,8 @@ export interface UseManagedResourcesResult { */ listChanged: boolean; refresh: () => Promise; + /** Acknowledge the list-changed indicator without fetching (#1721). */ + clearListChanged: () => void; } /** @@ -82,10 +84,17 @@ export function useManagedResources( // clearing afterward would wipe that genuinely-new signal and the user // would miss it. Clearing up front acknowledges only the change in hand. managedResourcesState.clearListChanged(); - const next = await managedResourcesState.refresh(); + // A user-initiated refresh forces a cache-bypassing round trip + // (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't + // return stale — and re-stores the fresh aggregate. + const next = await managedResourcesState.refresh(undefined, "refresh"); setResources(next); return next; }, [client, managedResourcesState]); - return { resources, listChanged, refresh }; + const clearListChanged = useCallback(() => { + managedResourcesState?.clearListChanged(); + }, [managedResourcesState]); + + return { resources, listChanged, refresh, clearListChanged }; } diff --git a/core/react/useManagedTools.ts b/core/react/useManagedTools.ts index 85eb5987..fa71452d 100644 --- a/core/react/useManagedTools.ts +++ b/core/react/useManagedTools.ts @@ -10,6 +10,13 @@ export interface UseManagedToolsResult { /** True when a `tools/list_changed` arrived since the last user refresh. */ listChanged: boolean; refresh: () => Promise; + /** + * Acknowledge the list-changed indicator without fetching the aggregate. + * Used by the paginated Refresh path, which reloads page 1 of the paged + * store (bypassing this hook's `refresh`) but must still clear the indicator + * the managed state lit on `list_changed` (#1721). + */ + clearListChanged: () => void; } /** @@ -66,10 +73,17 @@ export function useManagedTools( // afterward would wipe that genuinely-new signal and the user would miss // it. Clearing up front acknowledges only the change in hand. managedToolsState.clearListChanged(); - const next = await managedToolsState.refresh(); + // A user-initiated refresh forces a cache-bypassing round trip + // (`cacheMode: "refresh"`) so a modern server's `ttlMs`-cached list can't + // return stale — and re-stores the fresh aggregate. + const next = await managedToolsState.refresh(undefined, "refresh"); setTools(next); return next; }, [client, managedToolsState]); - return { tools, listChanged, refresh }; + const clearListChanged = useCallback(() => { + managedToolsState?.clearListChanged(); + }, [managedToolsState]); + + return { tools, listChanged, refresh, clearListChanged }; } diff --git a/core/react/usePagedPrompts.ts b/core/react/usePagedPrompts.ts index f8463170..94650c42 100644 --- a/core/react/usePagedPrompts.ts +++ b/core/react/usePagedPrompts.ts @@ -10,6 +10,10 @@ import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; export interface UsePagedPromptsResult { prompts: Prompt[]; + /** The server's `nextCursor` from the last page (undefined = at the end). */ + nextCursor?: string; + /** Pages loaded since the last reset (page 1 = 1). */ + pageCount: number; loadPage: ( cursor?: string, metadata?: Record, @@ -18,7 +22,9 @@ export interface UsePagedPromptsResult { } /** - * React hook that subscribes to PagedPromptsState and returns prompts + loadPage. + * React hook that subscribes to PagedPromptsState and returns prompts + + * pagination progress + loadPage. The state store owns loading; this mirrors + * its observable state. */ export function usePagedPrompts( client: InspectorClientProtocol | null, @@ -27,21 +33,42 @@ export function usePagedPrompts( const [prompts, setPrompts] = useState( pagedPromptsState?.getPrompts() ?? [], ); + const [nextCursor, setNextCursor] = useState( + pagedPromptsState?.getPagination().nextCursor, + ); + const [pageCount, setPageCount] = useState( + pagedPromptsState?.getPagination().pageCount ?? 0, + ); useEffect(() => { if (!pagedPromptsState) { setPrompts([]); + setNextCursor(undefined); + setPageCount(0); return; } setPrompts(pagedPromptsState.getPrompts()); + setNextCursor(pagedPromptsState.getPagination().nextCursor); + setPageCount(pagedPromptsState.getPagination().pageCount); const onPromptsChange = ( event: TypedEventGeneric, ) => { setPrompts(event.detail); }; + const onPaginationChange = ( + event: TypedEventGeneric, + ) => { + setNextCursor(event.detail.nextCursor); + setPageCount(event.detail.pageCount); + }; pagedPromptsState.addEventListener("promptsChange", onPromptsChange); + pagedPromptsState.addEventListener("paginationChange", onPaginationChange); return () => { pagedPromptsState.removeEventListener("promptsChange", onPromptsChange); + pagedPromptsState.removeEventListener( + "paginationChange", + onPaginationChange, + ); }; }, [pagedPromptsState]); @@ -53,9 +80,7 @@ export function usePagedPrompts( if (!pagedPromptsState || !client) { return { prompts: [], nextCursor: undefined }; } - const result = await pagedPromptsState.loadPage(cursor, metadata); - setPrompts(pagedPromptsState.getPrompts()); - return result; + return pagedPromptsState.loadPage(cursor, metadata); }, [client, pagedPromptsState], ); @@ -64,5 +89,5 @@ export function usePagedPrompts( pagedPromptsState?.clear(); }, [pagedPromptsState]); - return { prompts, loadPage, clear }; + return { prompts, nextCursor, pageCount, loadPage, clear }; } diff --git a/core/react/usePagedResources.ts b/core/react/usePagedResources.ts index a52096cd..25336a5d 100644 --- a/core/react/usePagedResources.ts +++ b/core/react/usePagedResources.ts @@ -10,6 +10,10 @@ import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; export interface UsePagedResourcesResult { resources: Resource[]; + /** The server's `nextCursor` from the last page (undefined = at the end). */ + nextCursor?: string; + /** Pages loaded since the last reset (page 1 = 1). */ + pageCount: number; loadPage: ( cursor?: string, metadata?: Record, @@ -18,7 +22,9 @@ export interface UsePagedResourcesResult { } /** - * React hook that subscribes to PagedResourcesState and returns resources + loadPage. + * React hook that subscribes to PagedResourcesState and returns resources + + * pagination progress + loadPage. The state store owns loading; this mirrors + * its observable state. */ export function usePagedResources( client: InspectorClientProtocol | null, @@ -27,24 +33,48 @@ export function usePagedResources( const [resources, setResources] = useState( pagedResourcesState?.getResources() ?? [], ); + const [nextCursor, setNextCursor] = useState( + pagedResourcesState?.getPagination().nextCursor, + ); + const [pageCount, setPageCount] = useState( + pagedResourcesState?.getPagination().pageCount ?? 0, + ); useEffect(() => { if (!pagedResourcesState) { setResources([]); + setNextCursor(undefined); + setPageCount(0); return; } setResources(pagedResourcesState.getResources()); + setNextCursor(pagedResourcesState.getPagination().nextCursor); + setPageCount(pagedResourcesState.getPagination().pageCount); const onResourcesChange = ( event: TypedEventGeneric, ) => { setResources(event.detail); }; + const onPaginationChange = ( + event: TypedEventGeneric, + ) => { + setNextCursor(event.detail.nextCursor); + setPageCount(event.detail.pageCount); + }; pagedResourcesState.addEventListener("resourcesChange", onResourcesChange); + pagedResourcesState.addEventListener( + "paginationChange", + onPaginationChange, + ); return () => { pagedResourcesState.removeEventListener( "resourcesChange", onResourcesChange, ); + pagedResourcesState.removeEventListener( + "paginationChange", + onPaginationChange, + ); }; }, [pagedResourcesState]); @@ -56,9 +86,7 @@ export function usePagedResources( if (!pagedResourcesState || !client) { return { resources: [], nextCursor: undefined }; } - const result = await pagedResourcesState.loadPage(cursor, metadata); - setResources(pagedResourcesState.getResources()); - return result; + return pagedResourcesState.loadPage(cursor, metadata); }, [client, pagedResourcesState], ); @@ -67,5 +95,5 @@ export function usePagedResources( pagedResourcesState?.clear(); }, [pagedResourcesState]); - return { resources, loadPage, clear }; + return { resources, nextCursor, pageCount, loadPage, clear }; } diff --git a/core/react/usePagedTools.ts b/core/react/usePagedTools.ts index 0f9254aa..026b5831 100644 --- a/core/react/usePagedTools.ts +++ b/core/react/usePagedTools.ts @@ -10,33 +10,61 @@ import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; export interface UsePagedToolsResult { tools: Tool[]; + /** The server's `nextCursor` from the last page (undefined = at the end). */ + nextCursor?: string; + /** Pages loaded since the last reset (page 1 = 1). */ + pageCount: number; loadPage: (cursor?: string) => Promise; clear: () => void; } /** - * React hook that subscribes to PagedToolsState and returns tools + loadPage. + * React hook that subscribes to PagedToolsState and returns tools + pagination + * progress + loadPage. The state store owns loading (incl. the connect-time + * page-1 load in paginated mode); this hook just mirrors its observable + * state. */ export function usePagedTools( client: InspectorClientProtocol | null, pagedToolsState: PagedToolsState | null, ): UsePagedToolsResult { const [tools, setTools] = useState(pagedToolsState?.getTools() ?? []); + const [nextCursor, setNextCursor] = useState( + pagedToolsState?.getPagination().nextCursor, + ); + const [pageCount, setPageCount] = useState( + pagedToolsState?.getPagination().pageCount ?? 0, + ); useEffect(() => { if (!pagedToolsState) { setTools([]); + setNextCursor(undefined); + setPageCount(0); return; } setTools(pagedToolsState.getTools()); + setNextCursor(pagedToolsState.getPagination().nextCursor); + setPageCount(pagedToolsState.getPagination().pageCount); const onToolsChange = ( event: TypedEventGeneric, ) => { setTools(event.detail); }; + const onPaginationChange = ( + event: TypedEventGeneric, + ) => { + setNextCursor(event.detail.nextCursor); + setPageCount(event.detail.pageCount); + }; pagedToolsState.addEventListener("toolsChange", onToolsChange); + pagedToolsState.addEventListener("paginationChange", onPaginationChange); return () => { pagedToolsState.removeEventListener("toolsChange", onToolsChange); + pagedToolsState.removeEventListener( + "paginationChange", + onPaginationChange, + ); }; }, [pagedToolsState]); @@ -45,9 +73,7 @@ export function usePagedTools( if (!pagedToolsState || !client) { return { tools: [], nextCursor: undefined }; } - const result = await pagedToolsState.loadPage(cursor); - setTools(pagedToolsState.getTools()); - return result; + return pagedToolsState.loadPage(cursor); }, [client, pagedToolsState], ); @@ -56,5 +82,5 @@ export function usePagedTools( pagedToolsState?.clear(); }, [pagedToolsState]); - return { tools, loadPage, clear }; + return { tools, nextCursor, pageCount, loadPage, clear }; } diff --git a/pr-screenshots/01-tools-all-pages-default.png b/pr-screenshots/01-tools-all-pages-default.png new file mode 100644 index 00000000..6b9ed126 Binary files /dev/null and b/pr-screenshots/01-tools-all-pages-default.png differ diff --git a/pr-screenshots/02-tools-single-page-first.png b/pr-screenshots/02-tools-single-page-first.png new file mode 100644 index 00000000..5232b0fd Binary files /dev/null and b/pr-screenshots/02-tools-single-page-first.png differ diff --git a/pr-screenshots/03-tools-single-page-load-more.png b/pr-screenshots/03-tools-single-page-load-more.png new file mode 100644 index 00000000..1e8e3bc1 Binary files /dev/null and b/pr-screenshots/03-tools-single-page-load-more.png differ diff --git a/pr-screenshots/04-server-setting-checkbox.png b/pr-screenshots/04-server-setting-checkbox.png new file mode 100644 index 00000000..9f6a7b7d Binary files /dev/null and b/pr-screenshots/04-server-setting-checkbox.png differ diff --git a/pr-screenshots/README.md b/pr-screenshots/README.md new file mode 100644 index 00000000..4c18af57 --- /dev/null +++ b/pr-screenshots/README.md @@ -0,0 +1,11 @@ +# Single-page pagination — proof of functionality (#1721) + +Captured against `test-servers/configs/pagination-http.json` (12 tools / +resources / prompts, `maxPageSize` 4 → 3 pages each), driven through the web app. + +| # | Screenshot | What it shows | +| - | ---------- | ------------- | +| 01 | `01-tools-all-pages-default.png` | Default **all-pages** mode: the **Paginated** switch is off and the sidebar shows the full aggregated list (all 12 tools). | +| 02 | `02-tools-single-page-first.png` | **Paginated** on: only page 1 (4 tools). The row is *Paginated (left) … Load next page (right)* with *1 page loaded* on its own line below. | +| 03 | `03-tools-single-page-load-more.png` | After clicking **Load next page**: the next 4 tools are appended (8 total) and the status reads *2 pages loaded*. | +| 04 | `04-server-setting-checkbox.png` | Server Settings → **Fetch Lists One Page at a Time** is checked — the sidebar toggle persisted the server-wide setting. | diff --git a/test-servers/configs/pagination-http.json b/test-servers/configs/pagination-http.json new file mode 100644 index 00000000..a1807fb5 --- /dev/null +++ b/test-servers/configs/pagination-http.json @@ -0,0 +1,18 @@ +{ + "serverInfo": { + "name": "pagination-showcase", + "version": "1.0.0" + }, + "tools": [{ "preset": "numbered_tools", "params": { "count": 12 } }], + "resources": [{ "preset": "numbered_resources", "params": { "count": 12 } }], + "prompts": [{ "preset": "numbered_prompts", "params": { "count": 12 } }], + "maxPageSize": { + "tools": 4, + "resources": 4, + "prompts": 4 + }, + "transport": { + "type": "streamable-http", + "port": 6600 + } +}