From 00f1733abe87add513da5abe7df14093062b898f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 27 Aug 2026 20:29:23 -0400 Subject: [PATCH 1/4] chore(web): extract App.tsx's server commands into useServerCommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-2 step 4 of the App.tsx decomposition (#2155, under #2129/#2126). Every command the screens issue against the live server moves into `useServerCommands`: calling a tool, getting a prompt, reading and subscribing to a resource, completing an argument, cancelling a task or a call, setting either log level, refreshing or paging a list, and flipping the pagination mode. All of them route through the command-scoped auth recovery `useOAuthRecovery` publishes, which is why this cluster cannot precede the OAuth one. The three in-flight result panels move with them, behind a small `useResultPanels` in the same file — split out only because of call order, the same reason `useHandshakeTelemetry` sits beside `useConnectionLifecycle`: `useOAuthRecovery` drops those panels on a session reset and routes a step-up failure into whichever one issued the command, and it runs before the commands hook. The move is inert — no behavior change, #2095's per-server pagination override included. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall --- clients/web/src/App.tsx | 763 ++-------- .../web/src/hooks/useServerCommands.test.tsx | 1230 +++++++++++++++++ clients/web/src/hooks/useServerCommands.tsx | 942 +++++++++++++ 3 files changed, 2254 insertions(+), 681 deletions(-) create mode 100644 clients/web/src/hooks/useServerCommands.test.tsx create mode 100644 clients/web/src/hooks/useServerCommands.tsx diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 41e49a528..91bf2c29f 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text } from "@mantine/core"; +import { Box } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import type { CreateMessageResult, @@ -12,11 +12,6 @@ import { InspectorClient } from "@inspector/core/mcp/index.js"; import { getServerType } from "@inspector/core/mcp/config.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; -import { - getUrlElicitationsFromError, - UrlElicitationLoopError, -} from "@inspector/core/mcp/urlElicitation.js"; -import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js"; import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js"; import type { InspectorServerSettings, @@ -24,10 +19,7 @@ import type { ServerEntry, ServerType, } from "@inspector/core/mcp/types.js"; -import { - DEFAULT_TASK_TTL_MS, - resolveModernLogLevel, -} from "@inspector/core/mcp/types.js"; +import { resolveModernLogLevel } from "@inspector/core/mcp/types.js"; import { cleanRoots, serializeMcpConfig, @@ -63,6 +55,7 @@ import { useHandshakeTelemetry, } from "./hooks/useConnectionLifecycle"; import { useMcpApps } from "./hooks/useMcpApps"; +import { useResultPanels, useServerCommands } from "./hooks/useServerCommands"; import { useExportActions } from "./hooks/useExportActions"; import { useProgressToasts } from "./hooks/useProgressToasts"; import { useTaskToasts } from "./hooks/useTaskToasts"; @@ -71,11 +64,6 @@ import { useInitialConfig } from "@inspector/core/react/useInitialConfig.js"; import { refreshingPersist } from "./lib/refreshingPersist"; import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; -import type { - ToolCallState, - ToolsUiState, -} from "./components/screens/ToolsScreen/ToolsScreen"; -import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen"; import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen"; import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost"; import type { LogEntryData } from "./components/elements/LogEntry/LogEntry"; @@ -111,20 +99,13 @@ import type { DeepLink, DeepLinkParseStatus } from "./utils/deepLink"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import { getAuthToken } from "./lib/authToken"; import { messagesToLogEntries } from "./lib/protocolReplay"; -import { - errorCodeOf, - errorMessage, - formatErrorDetails, -} from "./utils/errorFormat"; import { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults"; -import type { StepUpSource } from "./utils/stepUp"; import { bodyDroppedToastId, CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID, } from "./utils/toasts/toastIds"; import { FetchBodyDroppedToastMessage } from "./components/elements/Toasts/FetchBodyDroppedToastMessage"; import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage"; -import { UrlElicitationErrorToastMessage } from "./components/elements/Toasts/UrlElicitationErrorToastMessage"; import { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar"; function App() { @@ -286,19 +267,18 @@ function App() { null, ); - // In-flight call panel state. Tracked here (rather than inside the - // respective screens) so the panels can reflect pending → ok/error - // transitions and so `onClear*` handlers can reset the panel without - // remounting the screen. - const [toolCallState, setToolCallState] = useState( - undefined, - ); - const [getPromptState, setGetPromptState] = useState< - GetPromptState | undefined - >(undefined); - const [readResourceState, setReadResourceState] = useState< - ReadResourceState | undefined - >(undefined); + // The three in-flight result panels, plus the `clearResultPanels` / + // `setSourceScopedError` writers `useOAuthRecovery` needs. Owned by the + // commands hook's file but called here, ahead of the OAuth hook that reads + // those two (#2155). + const panels = useResultPanels(); + const { + toolCallState, + getPromptState, + readResourceState, + clearResultPanels, + setSourceScopedError, + } = panels; // Per-screen selection / search / filter state, one object per screen. Lifted // here (out of the individual screens) so it persists across tab navigation @@ -454,42 +434,6 @@ function App() { stderrLogs, } = useInspectorStores({ inspectorClient, connected, paginatedLists }); - /** Drops every in-flight result panel; see `clearResultPanels` below. */ - const clearResultPanels = useCallback(() => { - setToolCallState(undefined); - setGetPromptState(undefined); - setReadResourceState(undefined); - }, []); - - /** - * Routes a step-up failure or cancellation to the panel that issued the - * command. `app` and `ambient` have no panel of their own — the App bridge - * surfaces its own message and an ambient challenge was never user-initiated - * — so they fall through to the no-op arm. - */ - const setSourceScopedError = useCallback( - (source: StepUpSource, message: string) => { - switch (source) { - case "tool": - setToolCallState({ status: "error", error: message }); - break; - case "prompt": - setGetPromptState((prev) => - prev ? { ...prev, status: "error", error: message } : prev, - ); - break; - case "resource": - setReadResourceState((prev) => - prev ? { ...prev, status: "error", error: message } : prev, - ); - break; - default: - break; - } - }, - [], - ); - /** * Published below, once the connect path's `setupClientForServer` exists — * it reads `onBeforeOAuthRedirect` and `sessionStorageAdapter` out of the @@ -832,170 +776,73 @@ function App() { [sessionRef, fetchLogRef], ); - // --- Action handlers that route directly to the InspectorClient. --- - - const onCallTool = useCallback( - async ( - name: string, - args: Record, - runAsTask?: boolean, - ) => { - if (!inspectorClient) return; - const tool = tools.find((t: Tool) => t.name === name); - if (!tool) return; - // Route through the task pipeline when the caller asked to (or the tool - // requires it) — but only if the server advertises task tool calls. Per - // spec a tool's `taskSupport` is considered only when the server declares - // `tasks.requests.tools.call`, so without it we never task-augment (even a - // "required" tool, which then surfaces callTool's "requires task support" - // error). The created task shows up on the Tasks screen via the - // `requestorTaskUpdated` events callToolStream dispatches, and its live - // status/progress surface as toasts + progress bar. - // Legacy servers advertise task tool calls via - // `tasks.requests.tools.call`. Modern servers (SEP-2663) instead negotiate - // the `io.modelcontextprotocol/tasks` extension and are server-directed: - // task creation is decided per-request by the server, so declaring the - // extension on the call (which the task path does) is what makes a returned - // task handle legal ("unsolicited handles"). Either era routes the flagged - // call through the streaming task pipeline. - const serverSupportsTaskToolCalls = - !!capabilities?.tasks?.requests?.tools?.call || - inspectorClient.isTasksExtensionNegotiated(); - const asTask = - serverSupportsTaskToolCalls && - (runAsTask || tool.execution?.taskSupport === "required"); - // Drop any prior call's task id before starting; a task-augmented call - // repopulates it via the `toolCallTaskUpdated` listener below, an ordinary - // call leaves it cleared (#1455). - activeToolCallTaskIdRef.current = undefined; - setToolCallState({ status: "pending" }); - try { - // ToolsScreen types the args as `Record` (it accepts - // anything the user types into the schema form). `callTool` requires - // `Record` — narrow at the boundary instead of - // claiming the object is empty (which the previous `as Record` cast did, misleadingly). - const invocation = asTask - ? await inspectorClient.callToolStream( - tool, - args as Record, - undefined, - undefined, - { ttl: activeServer?.settings?.taskTtl || DEFAULT_TASK_TTL_MS }, - ) - : await inspectorClient.callTool( - tool, - args as Record, - ); - setToolCallState({ - status: invocation.success ? "ok" : "error", - result: invocation.result ?? undefined, - error: invocation.error, - }); - } catch (err) { - if (err instanceof AuthRecoveryRequiredError) { - setToolCallState(undefined); - if (activeServerId) { - await handleCommandScopedAuthRecovery(err, { - serverId: activeServerId, - source: "tool", - }); - } - return; - } - // The user cancelled the in-flight call (Cancel button → cancelToolCall). - // The cancellation notification was already sent to the server, so just - // clear the executing state — surfacing it as an error would read as a - // failure rather than the deliberate cancel it was (#1458). - if (err instanceof ToolCallCancelledError) { - setToolCallState(undefined); - notifications.show({ - title: "Tool call cancelled", - message: "A cancellation request was sent to the server.", - color: "gray", - autoClose: 3000, - }); - return; - } - // The server kept asking for a URL the user already completed this call, - // so callTool aborted to avoid an endless re-prompt loop. Surface that - // explicitly rather than as a generic failure. - if (err instanceof UrlElicitationLoopError) { - setToolCallState({ status: "error", error: err.message }); - notifications.show({ - autoClose: false, - title: "URL elicitation loop", - color: "yellow", - message: ( - - The server requested the same URL again after you completed it ( - {err.url}), so the call was cancelled to avoid an endless loop. - - ), - }); - return; - } - // A URLElicitationRequired (-32042) error that reaches here carried no - // elicitations (a non-spec response — the with-list case is handled and - // retried inside callTool). There's no URL to open, so surface a short - // toast that links to the raw error rather than a bare error panel. - const urlElicitations = getUrlElicitationsFromError(err); - if (urlElicitations !== null && urlElicitations.length === 0) { - const details = { - toolName: name, - details: formatErrorDetails(err), - }; - setToolCallState({ status: "error", error: errorMessage(err) }); - notifications.show({ - autoClose: false, - title: "URL elicitation required", - color: "yellow", - message: ( - setUrlElicitationErrorDetails(details)} - /> - ), - }); - return; - } - setToolCallState({ - status: "error", - error: errorMessage(err), - errorCode: errorCodeOf(err), - }); - } - }, - [ - inspectorClient, - tools, - activeServer, - capabilities, - activeServerId, - handleCommandScopedAuthRecovery, - activeToolCallTaskIdRef, - ], - ); - - const onClearToolResult = useCallback(() => { - setToolCallState(undefined); - }, []); - - // Tools UI changes flow through here so selecting a *different* tool also - // drops the previous tool's result — the result panel renders `toolCallState` - // regardless of selection, so without this a stale result would linger under - // the newly-selected tool (which has no result of its own yet). Search and - // form edits keep `selectedToolKey` unchanged, so they leave the result be. - // Depends on `selectedToolKey` only (not the whole `toolsUi`), so a search - // keystroke doesn't churn the callback identity. - const onToolsUiChange = useCallback( - (next: ToolsUiState) => { - if (next.selectedToolKey !== ui.toolsUi.selectedToolKey) { - setToolCallState(undefined); - } - setUi.setToolsUi(next); - }, - [ui.toolsUi.selectedToolKey, setUi], - ); + // --- Action handlers that route directly to the InspectorClient. Every one + // of them routes through the command-scoped auth recovery above, which is + // why they sit after it (#2155). --- + const { + onCallTool, + onClearToolResult, + onToolsUiChange, + onGetPrompt, + onReadResource, + onReadResourceContents, + onSubscribeResource, + onUnsubscribeResource, + onCompleteArgument, + onCancelTask, + onCancelToolCall, + onClearCompletedTasks, + onSetLogLevel, + onSetModernLogLevel, + onRefreshTools, + onRefreshPrompts, + onRefreshResources, + onRefreshTasks, + onTogglePaginatedLists, + onLoadMoreTools, + onLoadMorePrompts, + onLoadMoreResources, + } = useServerCommands({ + sessionRef, + servers, + activeServerId, + activeServer, + inspectorClient, + connected, + capabilities, + tools, + panels, + selectedToolKey: ui.toolsUi.selectedToolKey, + setToolsUi: setUi.setToolsUi, + setUrlElicitationErrorDetails, + setCurrentLogLevel, + setModernLogLevel, + activeToolCallTaskIdRef, + clearCompletedTasks, + refreshTasks, + paginatedLists, + paginatedListsOverride, + toolsPagination, + promptsPagination, + resourcesPagination, + refreshTools, + refreshPrompts, + refreshResources, + refreshResourceTemplates, + clearToolsListChanged, + clearPromptsListChanged, + clearResourcesListChanged, + loadToolsPage, + loadPromptsPage, + loadResourcesPage, + lastPersistedSettings, + applyLiveServerSettings, + updateServerSettings, + refreshInitialConfig, + handleCommandScopedAuthRecovery, + runWithCommandAuthRecovery, + runCommandInBackground, + }); // --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel), // these route the tool input/result into the running app via the renderer's @@ -1102,445 +949,6 @@ function App() { void appRendererRef.current?.teardown(); }, [appRendererRef]); - const onGetPrompt = useCallback( - async (name: string, args: Record) => { - if (!inspectorClient) return; - // Tag the in-flight + final state with the prompt name so the - // PromptsScreen can guard against showing a stale result for a - // prompt the user has already navigated away from. - setGetPromptState({ status: "pending", promptName: name }); - try { - const invocation = await inspectorClient.getPrompt(name, args); - setGetPromptState({ - status: "ok", - promptName: name, - result: invocation.result, - }); - } catch (err) { - if (err instanceof AuthRecoveryRequiredError) { - setGetPromptState(undefined); - if (activeServerId) { - await handleCommandScopedAuthRecovery(err, { - serverId: activeServerId, - source: "prompt", - }); - } - return; - } - setGetPromptState({ - status: "error", - promptName: name, - error: err instanceof Error ? err.message : String(err), - }); - } - }, - [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], - ); - - const onReadResource = useCallback( - async (uri: string) => { - if (!inspectorClient) return; - setReadResourceState({ status: "pending", uri }); - try { - const invocation = await inspectorClient.readResource(uri); - setReadResourceState({ - status: "ok", - uri, - result: invocation.result, - lastUpdated: invocation.timestamp, - }); - } catch (err) { - if (err instanceof AuthRecoveryRequiredError) { - setReadResourceState(undefined); - if (activeServerId) { - await handleCommandScopedAuthRecovery(err, { - serverId: activeServerId, - source: "resource", - }); - } - return; - } - setReadResourceState({ - status: "error", - uri, - error: err instanceof Error ? err.message : String(err), - }); - } - }, - [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], - ); - - // Read-on-demand handler for `resource_link` blocks in a tool result. Unlike - // `onReadResource` (which drives the Resources screen's preview panel via - // shared state), this returns the contents directly so each ResourceLink can - // own and inline its own fetched content. - const onReadResourceContents = useCallback( - async (uri: string) => { - if (!inspectorClient) throw new Error("Client is not connected"); - const read = () => inspectorClient.readResource(uri); - try { - const invocation = await read(); - return invocation.result; - } catch (err) { - if (err instanceof AuthRecoveryRequiredError && activeServerId) { - const satisfied = await handleCommandScopedAuthRecovery(err, { - serverId: activeServerId, - source: "resource", - }); - if (satisfied) { - const retry = await read(); - return retry.result; - } - } - throw err; - } - }, - [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], - ); - - const onSubscribeResource = useCallback( - (uri: string) => { - if (!inspectorClient) return; - void inspectorClient.subscribeToResource(uri); - }, - [inspectorClient], - ); - - const onUnsubscribeResource = useCallback( - (uri: string) => { - if (!inspectorClient) return; - void inspectorClient.unsubscribeFromResource(uri); - }, - [inspectorClient], - ); - - const onCompleteArgument = useCallback( - async ( - ref: - | { type: "ref/resource"; uri: string } - | { type: "ref/prompt"; name: string }, - argumentName: string, - argumentValue: string, - context: Record, - ): Promise => { - if (!inspectorClient) return []; - const result = await runWithCommandAuthRecovery( - () => - inspectorClient.getCompletions( - ref, - argumentName, - argumentValue, - context, - ), - "tool", - ); - return result?.values ?? []; - }, - [inspectorClient, runWithCommandAuthRecovery], - ); - - const onCancelTask = useCallback( - async (taskId: string) => { - if (!inspectorClient) return; - try { - await runWithCommandAuthRecovery( - () => inspectorClient.cancelRequestorTask(taskId), - "tool", - ); - } catch (err) { - if (err instanceof AuthRecoveryRequiredError) { - return; - } - notifications.show({ - title: "Failed to cancel task", - message: err instanceof Error ? err.message : String(err), - color: "red", - }); - } - }, - [inspectorClient, runWithCommandAuthRecovery], - ); - - // Cancel the in-flight tool call. A task-augmented call (run-as-task) has a - // server-side task, so cancel that via the tasks API (#1455) — the cancelled - // status then flows back through the managed task store and toasts, the same - // as cancelling from the Tasks screen. An ordinary call has no task, so abort - // its request: the SDK sends a `notifications/cancelled` to the server (the - // MCP cancellation flow) and the pending call rejects with a - // ToolCallCancelledError that `onCallTool` clears as a cancellation (#1458). - const onCancelToolCall = useCallback(() => { - if (!inspectorClient) return; - const taskId = activeToolCallTaskIdRef.current; - if (taskId) { - // Clear the ref before the call resolves so a rapid second Cancel click - // doesn't re-cancel the now-terminating task (which would surface a - // spurious "Failed to cancel task" toast). - activeToolCallTaskIdRef.current = undefined; - void onCancelTask(taskId); - return; - } - inspectorClient.cancelToolCall(); - }, [inspectorClient, onCancelTask, activeToolCallTaskIdRef]); - - const onClearCompletedTasks = useCallback(() => { - clearCompletedTasks(); - }, [clearCompletedTasks]); - - const onSetLogLevel = useCallback( - (level: LoggingLevel) => { - setCurrentLogLevel(level); - if (!inspectorClient) return; - // Nothing else records a `logging/setLevel` failure, and the optimistic - // level above already moved — so report it, or the selector would sit on - // a level the server never accepted with no explanation. - runCommandInBackground( - () => inspectorClient.setLoggingLevel(level), - "ambient", - "Failed to set log level", - ); - }, - [inspectorClient, runCommandInBackground], - ); - - // Modern era (#1629): no request is sent — the client stores the level and - // stamps it on every subsequent request's `_meta`. `null` opts back out. - const onSetModernLogLevel = useCallback( - (level: LoggingLevel | null) => { - setModernLogLevel(level); - inspectorClient?.setModernLogLevel(level ?? undefined); - }, - [inspectorClient], - ); - - // 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(() => { - 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(); - runCommandInBackground(() => toolsPagination.onRefresh(), "ambient"); - } else { - runCommandInBackground(() => refreshTools(), "ambient"); - } - }, [ - paginatedLists, - toolsPagination, - refreshTools, - clearToolsListChanged, - runCommandInBackground, - ]); - const onRefreshPrompts = useCallback(() => { - if (paginatedLists) { - clearPromptsListChanged(); - runCommandInBackground(() => promptsPagination.onRefresh(), "ambient"); - } else { - runCommandInBackground(() => refreshPrompts(), "ambient"); - } - }, [ - paginatedLists, - promptsPagination, - refreshPrompts, - clearPromptsListChanged, - runCommandInBackground, - ]); - const onRefreshResources = useCallback(() => { - if (paginatedLists) { - clearResourcesListChanged(); - runCommandInBackground(() => resourcesPagination.onRefresh(), "ambient"); - // Resource templates always use the managed (aggregate) path. - runCommandInBackground(() => refreshResourceTemplates(), "ambient"); - } else { - runCommandInBackground(async () => { - await refreshResources(); - await refreshResourceTemplates(); - }, "ambient"); - } - }, [ - paginatedLists, - resourcesPagination, - refreshResources, - refreshResourceTemplates, - clearResourcesListChanged, - runCommandInBackground, - ]); - // 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) => { - const current = servers.find((s) => s.id === activeServerId); - if (!current || activeServerId === undefined) return; - // Recorded against this server rather than app-wide, so it survives a - // switch away and back (#2095). Ordered after the id guard because the - // record is keyed by that id; with no active server there is nothing the - // toggle could have been flipped for. - paginatedListsOverride.record(activeServerId, value); - // Not `current.settings` directly: that entry only advances on a - // successful list read, so once one has failed it describes disk as it - // was *before* the writes made since. Build on the last write known to - // have landed while that is the fresher account (#2089). - const next: InspectorServerSettings = { - ...(lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS), - 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) { - runCommandInBackground(() => loadToolsPage(undefined), "ambient"); - runCommandInBackground(() => loadPromptsPage(undefined), "ambient"); - runCommandInBackground(() => loadResourcesPage(undefined), "ambient"); - } else { - runCommandInBackground(() => refreshTools(), "ambient"); - runCommandInBackground(() => refreshPrompts(), "ambient"); - runCommandInBackground(() => refreshResources(), "ambient"); - } - } - // Refreshed like every other secret-store mutation: this resends the - // server's rehydrated secrets, so it can trigger the pending - // plaintext-to-encrypted upgrade even though the user only toggled - // pagination (#1950 review r22). - // Announced before the request goes out, so two toggles in flight at once - // are ordered by when they were issued rather than by which one's list - // reload finished first (#2089). - const write = lastPersistedSettings.begin(activeServerId); - // This value is on disk now. Remember it as the rollback baseline for - // whatever is written next, since the `servers` entry it was derived - // from will keep describing the old value if the reload behind this - // write — or any later one — fails (#2089). - // - // Re-apply it when this write is the settled one: an overlapping toggle - // that failed *first* rolled the UI and the live client back to a - // baseline this write has since replaced, and if the list read behind - // this write failed too, nothing else would ever correct them. Through - // the *current* client, not this continuation's closure: a reconnect to - // the same server passes the id check while the captured instance is - // already destroyed. - const settlePaginationWrite = () => { - const settled = write.landed(next); - if (!settled) return; - // The override is keyed by server, so it is re-applied whatever is - // active now — it is this server's value and is only ever displayed - // while this server is the active one. The live client is not: it - // belongs to whichever server is connected (#2095). - paginatedListsOverride.record( - activeServerId, - next.paginatedLists ?? false, - ); - if (sessionRef.current.activeServerId === activeServerId) { - applyLiveServerSettings(next); - } - }; - void refreshingPersist(updateServerSettings, refreshInitialConfig)( - activeServerId, - next, - ) - .then(settlePaginationWrite) - .catch((err: unknown) => { - // A `ServerListReloadError` means the PUT landed and only reading the - // list back failed, so the new setting IS on disk (#1914). That is a - // landed write, not a failed one: rolling back would put the UI and - // the live client on the *old* value and contradict disk. Settle it - // exactly as the success path does and report only the failed reload. - if (err instanceof ServerListReloadError) { - settlePaginationWrite(); - notifications.show({ - title: - "Pagination setting saved, but the server list did not reload", - message: err.message, - color: "red", - }); - return; - } - // This write is over and never reached disk, so it stops counting as - // in flight: an earlier write still running is the settled state once - // it lands, and is what re-applies the UI this rollback is about to - // set (#2089). - write.failed(); - // Persist failed: revert the optimistic override 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). - // - // The baseline is resolved *here*, not captured when this write was - // issued: another toggle can land in between, and its value is what - // disk holds by the time this one fails. The override is set to that - // baseline rather than cleared, because clearing it falls back to - // `persistedPaginatedLists` — read from a `servers` entry that may be - // stale, showing the same wrong value from the other side (#2089). - // - // The override is recorded whatever is active by the time this - // rejection arrives: it is keyed by this write's server and is only - // ever displayed while that server is the active one, so a switch in - // between costs nothing and dropping it would leave the stale entry - // to answer for A the next time it comes back (#2095). - // - // The live client is the half that stays gated — it belongs to - // whichever server is connected now, so pushing this server's value - // into it after a switch would apply it to another one. It is taken - // from the ref for the same reason the success path does: a reconnect - // to the same server passes the id check while this continuation's - // captured instance is already destroyed. - const baseline = - lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS; - paginatedListsOverride.record( - activeServerId, - baseline.paginatedLists ?? false, - ); - if (sessionRef.current.activeServerId === activeServerId) { - applyLiveServerSettings(baseline); - } - notifications.show({ - title: "Failed to save pagination setting", - message: err instanceof Error ? err.message : String(err), - color: "red", - }); - }); - }, - [ - sessionRef, - servers, - activeServerId, - lastPersistedSettings, - paginatedListsOverride, - applyLiveServerSettings, - inspectorClient, - updateServerSettings, - refreshInitialConfig, - connected, - loadToolsPage, - loadPromptsPage, - loadResourcesPage, - refreshTools, - refreshPrompts, - refreshResources, - runCommandInBackground, - ], - ); - // 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( - () => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"), - [toolsPagination, runCommandInBackground], - ); - const onLoadMorePrompts = useCallback( - () => - runCommandInBackground(() => promptsPagination.onLoadMore(), "ambient"), - [promptsPagination, runCommandInBackground], - ); - const onLoadMoreResources = useCallback( - () => - runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"), - [resourcesPagination, runCommandInBackground], - ); const toolsPaginationControls: ListPaginationControlsProps = { paginated: toolsPagination.paginated, onPaginatedChange: onTogglePaginatedLists, @@ -1562,13 +970,6 @@ function App() { loadedPages: resourcesPagination.loadedPages, onLoadMore: onLoadMoreResources, }; - const onRefreshTasks = useCallback(() => { - runCommandInBackground( - () => refreshTasks(), - "ambient", - "Failed to refresh tasks", - ); - }, [refreshTasks, runCommandInBackground]); // Clear / Export / Replay for the four log-ish views (Logs, Protocol, // Network, Console), plus the Protocol per-section variants. diff --git a/clients/web/src/hooks/useServerCommands.test.tsx b/clients/web/src/hooks/useServerCommands.test.tsx new file mode 100644 index 000000000..226c8a297 --- /dev/null +++ b/clients/web/src/hooks/useServerCommands.test.tsx @@ -0,0 +1,1230 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { ReactElement } from "react"; +import { + UrlElicitationRequiredError, + type ServerCapabilities, + type Tool, +} from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { + InspectorServerSettings, + ServerEntry, +} from "@inspector/core/mcp/types.js"; +import { UrlElicitationLoopError } from "@inspector/core/mcp/urlElicitation.js"; +import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { ServerListReloadError } from "@inspector/core/react/useServers.js"; +import { renderWithMantine, act, waitFor } from "../test/renderWithMantine"; +import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults"; +import { useSessionRef } from "./useSessionRef"; +import type { PaginatedListModel } from "./usePaginatedList"; +import { + useResultPanels, + useServerCommands, + type ResultPanels, + type ServerCommands, +} from "./useServerCommands"; +import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen"; + +// --- Module doubles --------------------------------------------------------- +// Only the toast layer, which every failure path reaches. The client is a bare +// prototype object (below) rather than a mocked module: these commands exist to +// translate a screen's arguments into client calls, so a mocked client module +// would leave that translation unverified. + +const { notificationsMock } = vi.hoisted(() => ({ + notificationsMock: { show: vi.fn(), update: vi.fn(), hide: vi.fn() }, +})); +vi.mock("@mantine/notifications", () => ({ notifications: notificationsMock })); + +// --- Fixtures --------------------------------------------------------------- + +/** + * `Object.create` does not run the constructor, so this is a real + * `InspectorClient` prototype chain with no transport behind it — enough for a + * hook that only calls the methods stubbed here. + */ +const client = (over: Record = {}): InspectorClient => + Object.assign(Object.create(InspectorClient.prototype), { + isTasksExtensionNegotiated: () => false, + callTool: vi.fn().mockResolvedValue({ success: true, result: {} }), + callToolStream: vi.fn().mockResolvedValue({ success: true, result: {} }), + cancelToolCall: vi.fn(), + cancelRequestorTask: vi.fn().mockResolvedValue(undefined), + getPrompt: vi.fn().mockResolvedValue({ result: { messages: [] } }), + readResource: vi + .fn() + .mockResolvedValue({ result: { contents: [] }, timestamp: 1 }), + subscribeToResource: vi.fn().mockResolvedValue(undefined), + unsubscribeFromResource: vi.fn().mockResolvedValue(undefined), + getCompletions: vi.fn().mockResolvedValue({ values: ["a"] }), + setLoggingLevel: vi.fn().mockResolvedValue(undefined), + setModernLogLevel: vi.fn(), + setServerSettings: vi.fn(), + ...over, + }) as InspectorClient; + +/** The empty Tools-tab UI state a change is built from. */ +const EMPTY_TOOLS_UI: ToolsUiState = { + formValues: {}, + search: "", + runAsTask: false, +}; + +const tool = (name: string, over: Partial = {}): Tool => ({ + name, + inputSchema: { type: "object" }, + ...over, +}); + +const entry = (id: string, over: Partial = {}): ServerEntry => ({ + id, + name: `Server ${id}`, + config: { type: "streamable-http", url: "https://mcp.example/mcp" }, + connection: { status: "disconnected" }, + ...over, +}); + +/** Capabilities that advertise legacy task tool calls. */ +const taskCapabilities: ServerCapabilities = { + tasks: { requests: { tools: { call: {} } } }, +}; + +/** A promise a test can settle after the session has moved on. */ +function deferred(): { + promise: Promise; + resolve: () => void; + reject: (err: unknown) => void; +} { + let resolve!: () => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const authError = () => + new AuthRecoveryRequiredError(new URL("https://auth.example/authorize"), { + reason: "unauthorized", + }); + +function pagination(over: Partial> = {}) { + return { + items: [] as T[], + paginated: false, + canLoadMore: false, + loadedPages: 1, + onRefresh: vi.fn().mockResolvedValue(undefined), + onLoadMore: vi.fn().mockResolvedValue(undefined), + ...over, + } as unknown as PaginatedListModel; +} + +interface HarnessProps { + servers?: ServerEntry[]; + activeServerId?: string; + activeServer?: ServerEntry; + client?: InspectorClient | null; + connected?: boolean; + capabilities?: ServerCapabilities; + tools?: Tool[]; + selectedToolKey?: string; + paginatedLists?: boolean; + persisted?: Record; + updateServerSettingsImpl?: ( + id: string, + settings: InspectorServerSettings, + ) => Promise; + /** Overrides for the four recovery wrappers `useOAuthRecovery` publishes. */ + recovery?: { + handleCommandScopedAuthRecovery?: (...args: never[]) => Promise; + }; +} + +function spies() { + const landed = vi.fn().mockReturnValue(true); + const failed = vi.fn(); + return { + landed, + failed, + begin: vi.fn(() => ({ landed, failed })), + resolveSettings: vi.fn(), + lastWriteFailed: vi.fn().mockReturnValue(false), + setToolsUi: vi.fn(), + setUrlElicitationErrorDetails: vi.fn(), + setCurrentLogLevel: vi.fn(), + setModernLogLevel: vi.fn(), + clearCompletedTasks: vi.fn(), + refreshTasks: vi.fn().mockResolvedValue(undefined), + refreshTools: vi.fn().mockResolvedValue(undefined), + refreshPrompts: vi.fn().mockResolvedValue(undefined), + refreshResources: vi.fn().mockResolvedValue(undefined), + refreshResourceTemplates: vi.fn().mockResolvedValue(undefined), + clearToolsListChanged: vi.fn(), + clearPromptsListChanged: vi.fn(), + clearResourcesListChanged: vi.fn(), + loadToolsPage: vi.fn().mockResolvedValue(undefined), + loadPromptsPage: vi.fn().mockResolvedValue(undefined), + loadResourcesPage: vi.fn().mockResolvedValue(undefined), + record: vi.fn(), + valueFor: vi.fn(), + applyLiveServerSettings: vi.fn(), + updateServerSettings: vi.fn().mockResolvedValue(undefined), + refreshInitialConfig: vi.fn(), + handleCommandScopedAuthRecovery: vi.fn().mockResolvedValue(false), + runCommandInBackgroundErrors: [] as unknown[], + }; +} + +type Spies = ReturnType; + +interface Harness { + api: () => ServerCommands; + /** The in-flight call's task id, written by App's task-toast listener. */ + taskIdRef: { current: string | undefined }; + panels: () => ResultPanels; + rerender: (next: HarnessProps) => void; + spies: Spies; + toolsPagination: PaginatedListModel; + promptsPagination: PaginatedListModel; + resourcesPagination: PaginatedListModel; +} + +function harness(initial: HarnessProps = {}): Harness { + let latest: ServerCommands | undefined; + let latestPanels: ResultPanels | undefined; + const s = spies(); + const toolsPagination = pagination(); + const promptsPagination = pagination(); + const resourcesPagination = pagination(); + const activeToolCallTaskIdRef: { current: string | undefined } = { + current: undefined, + }; + + /** + * The real wrappers are thin: `runWithCommandAuthRecovery` awaits the + * operation and retries once through the recovery, and + * `runCommandInBackground` is its fire-and-forget form. Reproducing that + * shape here (rather than stubbing them as pass-throughs) is what makes the + * commands' own auth branches reachable. + */ + function Probe({ p }: { p: HarnessProps }) { + const servers = p.servers ?? []; + const sessionRef = useSessionRef({ + activeServerId: p.activeServerId, + servers, + inspectorClient: p.client ?? null, + }); + const panels = useResultPanels(); + latestPanels = panels; + const handleCommandScopedAuthRecovery = + (p.recovery?.handleCommandScopedAuthRecovery as + | typeof s.handleCommandScopedAuthRecovery + | undefined) ?? s.handleCommandScopedAuthRecovery; + const runWithCommandAuthRecovery = async ( + operation: () => Promise, + ): Promise => operation(); + latest = useServerCommands({ + sessionRef, + servers, + activeServerId: p.activeServerId, + activeServer: p.activeServer, + inspectorClient: p.client ?? null, + connected: p.connected ?? false, + capabilities: p.capabilities, + tools: p.tools ?? [], + panels, + selectedToolKey: p.selectedToolKey, + setToolsUi: s.setToolsUi, + setUrlElicitationErrorDetails: s.setUrlElicitationErrorDetails, + setCurrentLogLevel: s.setCurrentLogLevel, + setModernLogLevel: s.setModernLogLevel, + activeToolCallTaskIdRef, + clearCompletedTasks: s.clearCompletedTasks, + refreshTasks: s.refreshTasks, + paginatedLists: p.paginatedLists ?? false, + paginatedListsOverride: { record: s.record, valueFor: s.valueFor }, + toolsPagination, + promptsPagination, + resourcesPagination, + refreshTools: s.refreshTools, + refreshPrompts: s.refreshPrompts, + refreshResources: s.refreshResources, + refreshResourceTemplates: s.refreshResourceTemplates, + clearToolsListChanged: s.clearToolsListChanged, + clearPromptsListChanged: s.clearPromptsListChanged, + clearResourcesListChanged: s.clearResourcesListChanged, + loadToolsPage: s.loadToolsPage, + loadPromptsPage: s.loadPromptsPage, + loadResourcesPage: s.loadResourcesPage, + lastPersistedSettings: { + begin: s.begin, + resolve: (id: string) => p.persisted?.[id] ?? s.resolveSettings(id), + lastWriteFailed: s.lastWriteFailed, + }, + applyLiveServerSettings: s.applyLiveServerSettings, + updateServerSettings: + p.updateServerSettingsImpl ?? s.updateServerSettings, + refreshInitialConfig: s.refreshInitialConfig, + handleCommandScopedAuthRecovery, + runWithCommandAuthRecovery, + runCommandInBackground: (operation, _source, errorTitle) => { + void operation().catch((err: unknown) => { + s.runCommandInBackgroundErrors.push(err); + if (errorTitle) { + notificationsMock.show({ title: errorTitle }); + } + }); + }, + }); + return null; + } + + const view = renderWithMantine(); + return { + api: () => { + if (!latest) throw new Error("hook did not render"); + return latest; + }, + panels: () => { + if (!latestPanels) throw new Error("hook did not render"); + return latestPanels; + }, + rerender: (next) => view.rerender(), + taskIdRef: activeToolCallTaskIdRef, + spies: s, + toolsPagination, + promptsPagination, + resourcesPagination, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("useResultPanels", () => { + it("starts with every panel empty and clears them all", async () => { + const h = harness(); + expect(h.panels().toolCallState).toBeUndefined(); + expect(h.panels().getPromptState).toBeUndefined(); + expect(h.panels().readResourceState).toBeUndefined(); + + await act(async () => { + h.panels().setToolCallState({ status: "pending" }); + h.panels().setGetPromptState({ status: "pending", promptName: "p" }); + h.panels().setReadResourceState({ status: "pending", uri: "u" }); + }); + expect(h.panels().toolCallState).toEqual({ status: "pending" }); + + await act(async () => h.panels().clearResultPanels()); + expect(h.panels().toolCallState).toBeUndefined(); + expect(h.panels().getPromptState).toBeUndefined(); + expect(h.panels().readResourceState).toBeUndefined(); + }); + + it("routes a step-up error to the panel that issued the command", async () => { + const h = harness(); + await act(async () => { + h.panels().setGetPromptState({ status: "pending", promptName: "p" }); + h.panels().setReadResourceState({ status: "pending", uri: "u" }); + }); + + await act(async () => h.panels().setSourceScopedError("tool", "nope")); + expect(h.panels().toolCallState).toEqual({ + status: "error", + error: "nope", + }); + + await act(async () => h.panels().setSourceScopedError("prompt", "nope")); + expect(h.panels().getPromptState).toMatchObject({ + status: "error", + error: "nope", + promptName: "p", + }); + + await act(async () => h.panels().setSourceScopedError("resource", "nope")); + expect(h.panels().readResourceState).toMatchObject({ + status: "error", + error: "nope", + uri: "u", + }); + }); + + it("leaves a prompt/resource panel alone when it holds nothing", async () => { + const h = harness(); + await act(async () => { + h.panels().setSourceScopedError("prompt", "nope"); + h.panels().setSourceScopedError("resource", "nope"); + }); + expect(h.panels().getPromptState).toBeUndefined(); + expect(h.panels().readResourceState).toBeUndefined(); + }); + + it("is a no-op for the sources with no panel of their own", async () => { + const h = harness(); + await act(async () => { + h.panels().setSourceScopedError("app", "nope"); + h.panels().setSourceScopedError("ambient", "nope"); + }); + expect(h.panels().toolCallState).toBeUndefined(); + expect(h.panels().getPromptState).toBeUndefined(); + expect(h.panels().readResourceState).toBeUndefined(); + }); +}); + +describe("onCallTool", () => { + it("does nothing without a client", async () => { + const h = harness({ tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {})); + expect(h.panels().toolCallState).toBeUndefined(); + }); + + it("does nothing when the named tool is not in the list", async () => { + const c = client(); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("missing", {})); + expect(c.callTool).not.toHaveBeenCalled(); + expect(h.panels().toolCallState).toBeUndefined(); + }); + + it("calls the tool and reports the result", async () => { + const c = client({ + callTool: vi.fn().mockResolvedValue({ success: true, result: { ok: 1 } }), + }); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", { a: 1 })); + expect(c.callTool).toHaveBeenCalledWith( + expect.objectContaining({ name: "echo" }), + { a: 1 }, + ); + expect(h.panels().toolCallState).toEqual({ + status: "ok", + result: { ok: 1 }, + error: undefined, + }); + }); + + it("reports an unsuccessful invocation as an error panel", async () => { + const c = client({ + callTool: vi + .fn() + .mockResolvedValue({ success: false, result: null, error: "boom" }), + }); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {})); + expect(h.panels().toolCallState).toEqual({ + status: "error", + result: undefined, + error: "boom", + }); + }); + + it("routes through the task pipeline when asked and the server allows it", async () => { + const c = client(); + const h = harness({ + client: c, + tools: [tool("echo")], + capabilities: taskCapabilities, + activeServer: entry("a", { + settings: { ...EMPTY_SETTINGS, taskTtl: 42 }, + }), + }); + await act(async () => h.api().onCallTool("echo", {}, true)); + expect(c.callToolStream).toHaveBeenCalledWith( + expect.objectContaining({ name: "echo" }), + {}, + undefined, + undefined, + { ttl: 42 }, + ); + expect(c.callTool).not.toHaveBeenCalled(); + }); + + it("task-routes a tool whose execution requires it", async () => { + const c = client(); + const h = harness({ + client: c, + tools: [tool("echo", { execution: { taskSupport: "required" } })], + capabilities: taskCapabilities, + }); + await act(async () => h.api().onCallTool("echo", {})); + expect(c.callToolStream).toHaveBeenCalled(); + }); + + it("task-routes when the modern tasks extension is negotiated", async () => { + const c = client({ isTasksExtensionNegotiated: () => true }); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {}, true)); + expect(c.callToolStream).toHaveBeenCalled(); + }); + + it("does not task-route when the server advertises no task tool calls", async () => { + const c = client(); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {}, true)); + expect(c.callTool).toHaveBeenCalled(); + expect(c.callToolStream).not.toHaveBeenCalled(); + }); + + it("falls back to the default TTL when the server carries none", async () => { + const c = client(); + const h = harness({ + client: c, + tools: [tool("echo")], + capabilities: taskCapabilities, + }); + await act(async () => h.api().onCallTool("echo", {}, true)); + const call = vi.mocked(c.callToolStream).mock.calls[0]; + expect(call?.[4]).toEqual({ ttl: expect.any(Number) }); + }); + + it("clears the panel and recovers on a lapsed authorization", async () => { + const recover = vi.fn().mockResolvedValue(true); + const c = client({ callTool: vi.fn().mockRejectedValue(authError()) }); + const h = harness({ + client: c, + tools: [tool("echo")], + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await act(async () => h.api().onCallTool("echo", {})); + expect(recover).toHaveBeenCalledWith( + expect.any(AuthRecoveryRequiredError), + { serverId: "a", source: "tool" }, + ); + expect(h.panels().toolCallState).toBeUndefined(); + }); + + it("skips the recovery when no server is active", async () => { + const recover = vi.fn().mockResolvedValue(true); + const c = client({ callTool: vi.fn().mockRejectedValue(authError()) }); + const h = harness({ + client: c, + tools: [tool("echo")], + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await act(async () => h.api().onCallTool("echo", {})); + expect(recover).not.toHaveBeenCalled(); + expect(h.panels().toolCallState).toBeUndefined(); + }); + + it("clears the panel and toasts on an explicit cancellation", async () => { + const c = client({ + callTool: vi.fn().mockRejectedValue(new ToolCallCancelledError("gone")), + }); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {})); + expect(h.panels().toolCallState).toBeUndefined(); + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ title: "Tool call cancelled" }), + ); + }); + + it("surfaces a URL elicitation loop as its own error", async () => { + const c = client({ + callTool: vi + .fn() + .mockRejectedValue( + new UrlElicitationLoopError("https://example/looped"), + ), + }); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {})); + expect(h.panels().toolCallState).toMatchObject({ + status: "error", + error: expect.stringContaining("looped"), + }); + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ title: "URL elicitation loop" }), + ); + }); + + it("surfaces a URLElicitationRequired carrying no elicitations", async () => { + // The non-spec shape: -32042 with an empty elicitation list, so there is no + // URL to open and the panel gets a toast linking to the raw error instead. + const c = client({ + callTool: vi.fn().mockRejectedValue(new UrlElicitationRequiredError([])), + }); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {})); + expect(h.panels().toolCallState).toMatchObject({ status: "error" }); + const shown = notificationsMock.show.mock.calls.at(-1)?.[0] as { + title: string; + message: ReactElement<{ onViewDetails: () => void }>; + }; + expect(shown.title).toBe("URL elicitation required"); + // The toast's link is what opens the raw-error modal; the details name the + // tool that failed rather than the message the panel already shows. + act(() => shown.message.props.onViewDetails()); + expect(h.spies.setUrlElicitationErrorDetails).toHaveBeenCalledWith( + expect.objectContaining({ toolName: "echo" }), + ); + }); + + it("reports any other failure with its code", async () => { + const err = Object.assign(new Error("bad"), { code: -32603 }); + const c = client({ callTool: vi.fn().mockRejectedValue(err) }); + const h = harness({ client: c, tools: [tool("echo")] }); + await act(async () => h.api().onCallTool("echo", {})); + expect(h.panels().toolCallState).toMatchObject({ + status: "error", + error: "bad", + errorCode: -32603, + }); + }); +}); + +describe("the tools panel writers", () => { + it("onClearToolResult drops the result", async () => { + const h = harness(); + await act(async () => h.panels().setToolCallState({ status: "pending" })); + await act(async () => h.api().onClearToolResult()); + expect(h.panels().toolCallState).toBeUndefined(); + }); + + it("onToolsUiChange drops the result when the selection changes", async () => { + const h = harness({ selectedToolKey: "a" }); + await act(async () => h.panels().setToolCallState({ status: "pending" })); + await act(async () => + h.api().onToolsUiChange({ ...EMPTY_TOOLS_UI, selectedToolKey: "b" }), + ); + expect(h.panels().toolCallState).toBeUndefined(); + expect(h.spies.setToolsUi).toHaveBeenCalledWith({ + ...EMPTY_TOOLS_UI, + selectedToolKey: "b", + }); + }); + + it("onToolsUiChange keeps the result when only the search changed", async () => { + const h = harness({ selectedToolKey: "a" }); + await act(async () => h.panels().setToolCallState({ status: "pending" })); + await act(async () => + h.api().onToolsUiChange({ + ...EMPTY_TOOLS_UI, + selectedToolKey: "a", + search: "e", + }), + ); + expect(h.panels().toolCallState).toEqual({ status: "pending" }); + }); +}); + +describe("onGetPrompt", () => { + it("does nothing without a client", async () => { + const h = harness(); + await act(async () => h.api().onGetPrompt("p", {})); + expect(h.panels().getPromptState).toBeUndefined(); + }); + + it("tags the result with the prompt name", async () => { + const c = client({ + getPrompt: vi.fn().mockResolvedValue({ result: { messages: [1] } }), + }); + const h = harness({ client: c }); + await act(async () => h.api().onGetPrompt("p", { x: "1" })); + expect(c.getPrompt).toHaveBeenCalledWith("p", { x: "1" }); + expect(h.panels().getPromptState).toEqual({ + status: "ok", + promptName: "p", + result: { messages: [1] }, + }); + }); + + it("recovers a lapsed authorization", async () => { + const recover = vi.fn().mockResolvedValue(true); + const c = client({ getPrompt: vi.fn().mockRejectedValue(authError()) }); + const h = harness({ + client: c, + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await act(async () => h.api().onGetPrompt("p", {})); + expect(recover).toHaveBeenCalledWith( + expect.any(AuthRecoveryRequiredError), + { + serverId: "a", + source: "prompt", + }, + ); + expect(h.panels().getPromptState).toBeUndefined(); + }); + + it("skips the recovery with no active server", async () => { + const recover = vi.fn().mockResolvedValue(true); + const c = client({ getPrompt: vi.fn().mockRejectedValue(authError()) }); + const h = harness({ + client: c, + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await act(async () => h.api().onGetPrompt("p", {})); + expect(recover).not.toHaveBeenCalled(); + }); + + it("reports any other failure against the prompt name", async () => { + const c = client({ getPrompt: vi.fn().mockRejectedValue("plain string") }); + const h = harness({ client: c }); + await act(async () => h.api().onGetPrompt("p", {})); + expect(h.panels().getPromptState).toEqual({ + status: "error", + promptName: "p", + error: "plain string", + }); + }); +}); + +describe("onReadResource", () => { + it("does nothing without a client", async () => { + const h = harness(); + await act(async () => h.api().onReadResource("u")); + expect(h.panels().readResourceState).toBeUndefined(); + }); + + it("reports the contents and the read timestamp", async () => { + const c = client({ + readResource: vi + .fn() + .mockResolvedValue({ result: { contents: [1] }, timestamp: 7 }), + }); + const h = harness({ client: c }); + await act(async () => h.api().onReadResource("u")); + expect(h.panels().readResourceState).toEqual({ + status: "ok", + uri: "u", + result: { contents: [1] }, + lastUpdated: 7, + }); + }); + + it("recovers a lapsed authorization", async () => { + const recover = vi.fn().mockResolvedValue(true); + const c = client({ readResource: vi.fn().mockRejectedValue(authError()) }); + const h = harness({ + client: c, + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await act(async () => h.api().onReadResource("u")); + expect(recover).toHaveBeenCalledWith( + expect.any(AuthRecoveryRequiredError), + { + serverId: "a", + source: "resource", + }, + ); + expect(h.panels().readResourceState).toBeUndefined(); + }); + + it("skips the recovery with no active server", async () => { + const recover = vi.fn().mockResolvedValue(true); + const c = client({ readResource: vi.fn().mockRejectedValue(authError()) }); + const h = harness({ + client: c, + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await act(async () => h.api().onReadResource("u")); + expect(recover).not.toHaveBeenCalled(); + }); + + it("reports any other failure against the uri", async () => { + const c = client({ + readResource: vi.fn().mockRejectedValue(new Error("nope")), + }); + const h = harness({ client: c }); + await act(async () => h.api().onReadResource("u")); + expect(h.panels().readResourceState).toEqual({ + status: "error", + uri: "u", + error: "nope", + }); + }); +}); + +describe("onReadResourceContents", () => { + it("throws when there is no client", async () => { + const h = harness(); + await expect(h.api().onReadResourceContents("u")).rejects.toThrow( + "Client is not connected", + ); + }); + + it("returns the contents directly", async () => { + const c = client({ + readResource: vi.fn().mockResolvedValue({ result: { contents: [2] } }), + }); + const h = harness({ client: c }); + await expect(h.api().onReadResourceContents("u")).resolves.toEqual({ + contents: [2], + }); + }); + + it("retries once after a satisfied recovery", async () => { + const recover = vi.fn().mockResolvedValue(true); + const readResource = vi + .fn() + .mockRejectedValueOnce(authError()) + .mockResolvedValue({ result: { contents: [3] } }); + const h = harness({ + client: client({ readResource }), + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await expect(h.api().onReadResourceContents("u")).resolves.toEqual({ + contents: [3], + }); + expect(readResource).toHaveBeenCalledTimes(2); + }); + + it("rethrows when the recovery was not satisfied", async () => { + const recover = vi.fn().mockResolvedValue(false); + const h = harness({ + client: client({ + readResource: vi.fn().mockRejectedValue(authError()), + }), + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await expect(h.api().onReadResourceContents("u")).rejects.toBeInstanceOf( + AuthRecoveryRequiredError, + ); + }); + + it("rethrows a non-auth failure untouched", async () => { + const recover = vi.fn(); + const h = harness({ + client: client({ + readResource: vi.fn().mockRejectedValue(new Error("nope")), + }), + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await expect(h.api().onReadResourceContents("u")).rejects.toThrow("nope"); + expect(recover).not.toHaveBeenCalled(); + }); +}); + +describe("subscriptions and completion", () => { + it("subscribes and unsubscribes through the client", async () => { + const c = client(); + const h = harness({ client: c }); + await act(async () => { + h.api().onSubscribeResource("u"); + h.api().onUnsubscribeResource("u"); + }); + expect(c.subscribeToResource).toHaveBeenCalledWith("u"); + expect(c.unsubscribeFromResource).toHaveBeenCalledWith("u"); + }); + + it("is a no-op for both without a client", async () => { + const h = harness(); + await act(async () => { + h.api().onSubscribeResource("u"); + h.api().onUnsubscribeResource("u"); + }); + // Reaching here without throwing is the assertion. + expect(h.api().onSubscribeResource).toBeTypeOf("function"); + }); + + it("returns the completion values", async () => { + const c = client(); + const h = harness({ client: c }); + const values = await h + .api() + .onCompleteArgument({ type: "ref/prompt", name: "p" }, "arg", "v", {}); + expect(values).toEqual(["a"]); + expect(c.getCompletions).toHaveBeenCalledWith( + { type: "ref/prompt", name: "p" }, + "arg", + "v", + {}, + ); + }); + + it("returns no completions without a client", async () => { + const h = harness(); + await expect( + h + .api() + .onCompleteArgument({ type: "ref/resource", uri: "u" }, "a", "", {}), + ).resolves.toEqual([]); + }); + + it("returns no completions when the result carries none", async () => { + const h = harness({ + client: client({ getCompletions: vi.fn().mockResolvedValue({}) }), + }); + await expect( + h + .api() + .onCompleteArgument({ type: "ref/prompt", name: "p" }, "a", "", {}), + ).resolves.toEqual([]); + }); +}); + +describe("cancellation and tasks", () => { + it("cancels a task through the client", async () => { + const c = client(); + const h = harness({ client: c }); + await act(async () => h.api().onCancelTask("t1")); + expect(c.cancelRequestorTask).toHaveBeenCalledWith("t1"); + }); + + it("does nothing without a client", async () => { + const h = harness(); + await act(async () => h.api().onCancelTask("t1")); + expect(notificationsMock.show).not.toHaveBeenCalled(); + }); + + it("stays silent when the cancel raised a recovery", async () => { + const h = harness({ + client: client({ + cancelRequestorTask: vi.fn().mockRejectedValue(authError()), + }), + }); + await act(async () => h.api().onCancelTask("t1")); + expect(notificationsMock.show).not.toHaveBeenCalled(); + }); + + it("toasts any other cancel failure", async () => { + const h = harness({ + client: client({ + cancelRequestorTask: vi.fn().mockRejectedValue(new Error("no")), + }), + }); + await act(async () => h.api().onCancelTask("t1")); + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ title: "Failed to cancel task" }), + ); + }); + + it("toasts a non-Error cancel rejection as its string form", async () => { + const h = harness({ + client: client({ + cancelRequestorTask: vi.fn().mockRejectedValue("plain"), + }), + }); + await act(async () => h.api().onCancelTask("t1")); + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ message: "plain" }), + ); + }); + + it("aborts an ordinary in-flight call", async () => { + const c = client(); + const h = harness({ client: c }); + await act(async () => h.api().onCancelToolCall()); + expect(c.cancelToolCall).toHaveBeenCalled(); + }); + + it("cancels the task behind a task-augmented call, once", async () => { + const c = client(); + const h = harness({ client: c, capabilities: taskCapabilities }); + // The ref is written by the task-toast listener in App; set it the way a + // live task-augmented call would. + h.taskIdRef.current = "t9"; + await act(async () => h.api().onCancelToolCall()); + expect(c.cancelRequestorTask).toHaveBeenCalledWith("t9"); + // The ref is cleared first, so a second click aborts the call instead of + // re-cancelling a task that is already terminating. + expect(h.taskIdRef.current).toBeUndefined(); + await act(async () => h.api().onCancelToolCall()); + expect(c.cancelRequestorTask).toHaveBeenCalledTimes(1); + expect(c.cancelToolCall).toHaveBeenCalledTimes(1); + }); + + it("is a no-op without a client", async () => { + const h = harness(); + await act(async () => h.api().onCancelToolCall()); + expect(h.api().onCancelToolCall).toBeTypeOf("function"); + }); + + it("clears the completed tasks", async () => { + const h = harness(); + await act(async () => h.api().onClearCompletedTasks()); + expect(h.spies.clearCompletedTasks).toHaveBeenCalled(); + }); + + it("refreshes the task list, reporting a failure", async () => { + const h = harness(); + await act(async () => h.api().onRefreshTasks()); + expect(h.spies.refreshTasks).toHaveBeenCalled(); + }); +}); + +describe("log levels", () => { + it("moves the legacy level optimistically and sends it", async () => { + const c = client(); + const h = harness({ client: c }); + await act(async () => h.api().onSetLogLevel("debug")); + expect(h.spies.setCurrentLogLevel).toHaveBeenCalledWith("debug"); + expect(c.setLoggingLevel).toHaveBeenCalledWith("debug"); + }); + + it("still moves the level with no client to send to", async () => { + const h = harness(); + await act(async () => h.api().onSetLogLevel("warning")); + expect(h.spies.setCurrentLogLevel).toHaveBeenCalledWith("warning"); + }); + + it("stores the modern level on the client, and opts back out", async () => { + const c = client(); + const h = harness({ client: c }); + await act(async () => h.api().onSetModernLogLevel("info")); + expect(c.setModernLogLevel).toHaveBeenCalledWith("info"); + await act(async () => h.api().onSetModernLogLevel(null)); + expect(c.setModernLogLevel).toHaveBeenCalledWith(undefined); + expect(h.spies.setModernLogLevel).toHaveBeenCalledWith(null); + }); + + it("records the modern level with no client", async () => { + const h = harness(); + await act(async () => h.api().onSetModernLogLevel("error")); + expect(h.spies.setModernLogLevel).toHaveBeenCalledWith("error"); + }); +}); + +describe("the refresh handlers", () => { + it("refresh the aggregates in all-pages mode", async () => { + const h = harness(); + await act(async () => { + h.api().onRefreshTools(); + h.api().onRefreshPrompts(); + h.api().onRefreshResources(); + }); + expect(h.spies.refreshTools).toHaveBeenCalled(); + expect(h.spies.refreshPrompts).toHaveBeenCalled(); + expect(h.spies.refreshResources).toHaveBeenCalled(); + expect(h.spies.refreshResourceTemplates).toHaveBeenCalled(); + expect(h.spies.clearToolsListChanged).not.toHaveBeenCalled(); + }); + + it("reload page 1 and acknowledge the indicator in paginated mode", async () => { + const h = harness({ paginatedLists: true }); + await act(async () => { + h.api().onRefreshTools(); + h.api().onRefreshPrompts(); + h.api().onRefreshResources(); + }); + expect(h.spies.clearToolsListChanged).toHaveBeenCalled(); + expect(h.spies.clearPromptsListChanged).toHaveBeenCalled(); + expect(h.spies.clearResourcesListChanged).toHaveBeenCalled(); + expect(h.toolsPagination.onRefresh).toHaveBeenCalled(); + expect(h.promptsPagination.onRefresh).toHaveBeenCalled(); + expect(h.resourcesPagination.onRefresh).toHaveBeenCalled(); + // Templates always take the aggregate path, both modes. + expect(h.spies.refreshResourceTemplates).toHaveBeenCalled(); + expect(h.spies.refreshTools).not.toHaveBeenCalled(); + }); + + it("page each list forward", async () => { + const h = harness(); + await act(async () => { + h.api().onLoadMoreTools(); + h.api().onLoadMorePrompts(); + h.api().onLoadMoreResources(); + }); + expect(h.toolsPagination.onLoadMore).toHaveBeenCalled(); + expect(h.promptsPagination.onLoadMore).toHaveBeenCalled(); + expect(h.resourcesPagination.onLoadMore).toHaveBeenCalled(); + }); +}); + +describe("onTogglePaginatedLists", () => { + const servers = [entry("a")]; + + it("does nothing when there is no matching active server", async () => { + const h = harness({ servers, activeServerId: "missing" }); + await act(async () => h.api().onTogglePaginatedLists(true)); + expect(h.spies.record).not.toHaveBeenCalled(); + expect(h.spies.updateServerSettings).not.toHaveBeenCalled(); + }); + + it("records the override, pushes it live and persists it", async () => { + const c = client(); + const h = harness({ servers, activeServerId: "a", client: c }); + await act(async () => h.api().onTogglePaginatedLists(true)); + expect(h.spies.record).toHaveBeenCalledWith("a", true); + expect(c.setServerSettings).toHaveBeenCalledWith( + expect.objectContaining({ paginatedLists: true }), + ); + await waitFor(() => + expect(h.spies.updateServerSettings).toHaveBeenCalledWith( + "a", + expect.objectContaining({ paginatedLists: true }), + ), + ); + expect(h.spies.refreshInitialConfig).toHaveBeenCalled(); + }); + + it("builds on the last landed write rather than on the list entry", async () => { + const h = harness({ + servers, + activeServerId: "a", + persisted: { a: { ...EMPTY_SETTINGS, maxFetchRequests: 9 } }, + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + await waitFor(() => + expect(h.spies.updateServerSettings).toHaveBeenCalledWith("a", { + ...EMPTY_SETTINGS, + maxFetchRequests: 9, + paginatedLists: true, + }), + ); + }); + + it("pulls page 1 into every paged store when connected and turning on", async () => { + const h = harness({ + servers, + activeServerId: "a", + connected: true, + client: client(), + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + expect(h.spies.loadToolsPage).toHaveBeenCalledWith(undefined); + expect(h.spies.loadPromptsPage).toHaveBeenCalledWith(undefined); + expect(h.spies.loadResourcesPage).toHaveBeenCalledWith(undefined); + }); + + it("refetches every aggregate when connected and turning off", async () => { + const h = harness({ + servers, + activeServerId: "a", + connected: true, + client: client(), + }); + await act(async () => h.api().onTogglePaginatedLists(false)); + expect(h.spies.refreshTools).toHaveBeenCalled(); + expect(h.spies.refreshPrompts).toHaveBeenCalled(); + expect(h.spies.refreshResources).toHaveBeenCalled(); + }); + + it("loads nothing while disconnected", async () => { + const h = harness({ servers, activeServerId: "a" }); + await act(async () => h.api().onTogglePaginatedLists(true)); + expect(h.spies.loadToolsPage).not.toHaveBeenCalled(); + expect(h.spies.refreshTools).not.toHaveBeenCalled(); + }); + + it("re-applies the value to the live client once the write settles", async () => { + const h = harness({ + servers, + activeServerId: "a", + client: client(), + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + await waitFor(() => + expect(h.spies.applyLiveServerSettings).toHaveBeenCalledWith( + expect.objectContaining({ paginatedLists: true }), + ), + ); + // The override is re-recorded for this server, whatever is active now. + expect(h.spies.record).toHaveBeenLastCalledWith("a", true); + }); + + it("does not re-apply when a later write has already settled", async () => { + const h = harness({ servers, activeServerId: "a", client: client() }); + h.spies.landed.mockReturnValue(false); + await act(async () => h.api().onTogglePaginatedLists(true)); + await waitFor(() => expect(h.spies.begin).toHaveBeenCalled()); + expect(h.spies.applyLiveServerSettings).not.toHaveBeenCalled(); + }); + + it("does not push into a client belonging to another server", async () => { + // The write is held open so the session can move on before it settles — + // resolving it inline would settle against the session that issued it. + const gate = deferred(); + const h = harness({ + servers, + activeServerId: "a", + client: client(), + updateServerSettingsImpl: vi.fn().mockReturnValue(gate.promise), + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + h.rerender({ servers, activeServerId: "b", client: client() }); + await act(async () => { + gate.resolve(); + await gate.promise; + }); + await waitFor(() => expect(h.spies.landed).toHaveBeenCalled()); + expect(h.spies.applyLiveServerSettings).not.toHaveBeenCalled(); + expect(h.spies.record).toHaveBeenLastCalledWith("a", true); + }); + + it("settles a landed write whose list reload failed, and says so", async () => { + const h = harness({ + servers, + activeServerId: "a", + client: client(), + updateServerSettingsImpl: vi + .fn() + .mockRejectedValue(new ServerListReloadError("reload failed")), + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + await waitFor(() => + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Pagination setting saved, but the server list did not reload", + }), + ), + ); + expect(h.spies.landed).toHaveBeenCalledWith( + expect.objectContaining({ paginatedLists: true }), + ); + expect(h.spies.failed).not.toHaveBeenCalled(); + }); + + it("rolls back to the resolved baseline when the write never landed", async () => { + const h = harness({ + servers, + activeServerId: "a", + client: client(), + persisted: { a: { ...EMPTY_SETTINGS, paginatedLists: false } }, + updateServerSettingsImpl: vi.fn().mockRejectedValue(new Error("nope")), + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + await waitFor(() => expect(h.spies.failed).toHaveBeenCalled()); + expect(h.spies.record).toHaveBeenLastCalledWith("a", false); + expect(h.spies.applyLiveServerSettings).toHaveBeenLastCalledWith( + expect.objectContaining({ paginatedLists: false }), + ); + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ title: "Failed to save pagination setting" }), + ); + }); + + it("falls back to empty settings when nothing is known about disk", async () => { + const h = harness({ + servers, + activeServerId: "a", + client: client(), + updateServerSettingsImpl: vi.fn().mockRejectedValue("plain"), + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + await waitFor(() => expect(h.spies.failed).toHaveBeenCalled()); + expect(h.spies.record).toHaveBeenLastCalledWith("a", false); + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ message: "plain" }), + ); + }); + + it("does not roll the live client back for another server's session", async () => { + const gate = deferred(); + const h = harness({ + servers, + activeServerId: "a", + client: client(), + updateServerSettingsImpl: vi.fn().mockReturnValue(gate.promise), + }); + await act(async () => h.api().onTogglePaginatedLists(true)); + h.rerender({ servers, activeServerId: "b", client: client() }); + await act(async () => { + gate.reject(new Error("nope")); + await gate.promise.catch(() => undefined); + }); + await waitFor(() => expect(h.spies.failed).toHaveBeenCalled()); + expect(h.spies.applyLiveServerSettings).not.toHaveBeenCalled(); + expect(h.spies.record).toHaveBeenLastCalledWith("a", false); + }); +}); diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx new file mode 100644 index 000000000..6db8674d6 --- /dev/null +++ b/clients/web/src/hooks/useServerCommands.tsx @@ -0,0 +1,942 @@ +import { useCallback, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { Text } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import type { + LoggingLevel, + ServerCapabilities, + Tool, +} from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { JsonValue } from "@inspector/core/mcp/index.js"; +import type { + InspectorServerSettings, + ServerEntry, +} from "@inspector/core/mcp/types.js"; +import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js"; +import { + getUrlElicitationsFromError, + UrlElicitationLoopError, +} from "@inspector/core/mcp/urlElicitation.js"; +import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { ServerListReloadError } from "@inspector/core/react/useServers.js"; +import type { SessionRef } from "./useSessionRef"; +import type { LastPersistedSettings } from "./useLastPersistedSettings"; +import type { PaginatedListsOverride } from "./usePaginatedListsOverride"; +import type { PaginatedListModel } from "./usePaginatedList"; +import type { TabUiStateSetters } from "./useTabUiState"; +import type { OAuthRecovery } from "./useOAuthRecovery"; +import { refreshingPersist } from "../lib/refreshingPersist"; +import type { + ToolCallState, + ToolsUiState, +} from "../components/screens/ToolsScreen/ToolsScreen"; +import type { GetPromptState } from "../components/screens/PromptsScreen/PromptsScreen"; +import type { ReadResourceState } from "../components/screens/ResourcesScreen/ResourcesScreen"; +import { UrlElicitationErrorToastMessage } from "../components/elements/Toasts/UrlElicitationErrorToastMessage"; +import { + errorCodeOf, + errorMessage, + formatErrorDetails, +} from "../utils/errorFormat"; +import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults"; +import type { StepUpSource } from "../utils/stepUp"; + +/** + * The three in-flight result panels, plus the two ways something other than a + * command writes to them. + * + * Split out of `useServerCommands` below only because of call order — the same + * reason `useHandshakeTelemetry` sits beside `useConnectionLifecycle`. + * `useOAuthRecovery` drops these panels on a session reset and routes a + * step-up failure into whichever one issued the command, and it runs *before* + * the commands hook, which consumes that hook's recovery wrappers. So the + * state has to exist earlier than the hook that otherwise owns it; keeping the + * pair here rather than in `App.tsx` keeps both halves in the file that owns + * the panels. + */ +export interface ResultPanels { + toolCallState: ToolCallState | undefined; + getPromptState: GetPromptState | undefined; + readResourceState: ReadResourceState | undefined; + setToolCallState: Dispatch>; + setGetPromptState: Dispatch>; + setReadResourceState: Dispatch>; + /** Drops every in-flight result panel. */ + clearResultPanels: () => void; + /** + * Routes a step-up failure or cancellation to the panel that issued the + * command. `app` and `ambient` have no panel of their own — the App bridge + * surfaces its own message and an ambient challenge was never user-initiated + * — so they fall through to the no-op arm. + */ + setSourceScopedError: (source: StepUpSource, message: string) => void; +} + +/** See {@link ResultPanels}. */ +export function useResultPanels(): ResultPanels { + // In-flight call panel state. Tracked here (rather than inside the + // respective screens) so the panels can reflect pending → ok/error + // transitions and so `onClear*` handlers can reset the panel without + // remounting the screen. + const [toolCallState, setToolCallState] = useState( + undefined, + ); + const [getPromptState, setGetPromptState] = useState< + GetPromptState | undefined + >(undefined); + const [readResourceState, setReadResourceState] = useState< + ReadResourceState | undefined + >(undefined); + + const clearResultPanels = useCallback(() => { + setToolCallState(undefined); + setGetPromptState(undefined); + setReadResourceState(undefined); + }, []); + + const setSourceScopedError = useCallback( + (source: StepUpSource, message: string) => { + switch (source) { + case "tool": + setToolCallState({ status: "error", error: message }); + break; + case "prompt": + setGetPromptState((prev) => + prev ? { ...prev, status: "error", error: message } : prev, + ); + break; + case "resource": + setReadResourceState((prev) => + prev ? { ...prev, status: "error", error: message } : prev, + ); + break; + default: + break; + } + }, + [], + ); + + return { + toolCallState, + getPromptState, + readResourceState, + setToolCallState, + setGetPromptState, + setReadResourceState, + clearResultPanels, + setSourceScopedError, + }; +} + +export interface UseServerCommandsOptions { + /** The stable session mirror from `useSessionRef` (#2129). */ + sessionRef: SessionRef; + servers: ServerEntry[]; + activeServerId: string | undefined; + /** The active server's catalog entry — its `taskTtl` bounds a task call. */ + activeServer: ServerEntry | undefined; + inspectorClient: InspectorClient | null; + /** Whether the session is live; gates the loads a mode flip implies. */ + connected: boolean; + /** Negotiated server capabilities — decides whether a call is taskable. */ + capabilities: ServerCapabilities | undefined; + /** The aggregated tool list a `tools/call` resolves its name against. */ + tools: Tool[]; + /** The panels these commands write into; see {@link ResultPanels}. */ + panels: ResultPanels; + /** The selected tool, so a *different* selection drops the stale result. */ + selectedToolKey: string | undefined; + setToolsUi: TabUiStateSetters["setToolsUi"]; + /** Opens the raw-error modal for a non-spec URLElicitationRequired. */ + setUrlElicitationErrorDetails: ( + details: { toolName: string; details: string } | null, + ) => void; + /** The optimistic log levels the Logs tab renders. */ + setCurrentLogLevel: (level: LoggingLevel) => void; + setModernLogLevel: (level: LoggingLevel | null) => void; + /** The task id of the in-flight tool call, if it was task-augmented. */ + activeToolCallTaskIdRef: { current: string | undefined }; + clearCompletedTasks: () => void; + refreshTasks: () => Promise; + + // --- The list stores and their two fetch modes (#1721). --- + paginatedLists: boolean; + paginatedListsOverride: PaginatedListsOverride; + toolsPagination: PaginatedListModel; + promptsPagination: PaginatedListModel; + resourcesPagination: PaginatedListModel; + refreshTools: () => Promise; + refreshPrompts: () => Promise; + refreshResources: () => Promise; + refreshResourceTemplates: () => Promise; + clearToolsListChanged: () => void; + clearPromptsListChanged: () => void; + clearResourcesListChanged: () => void; + loadToolsPage: (cursor: string | undefined) => Promise; + loadPromptsPage: (cursor: string | undefined) => Promise; + loadResourcesPage: (cursor: string | undefined) => Promise; + + // --- Persisting the pagination setting (#1721/#2089/#2095). --- + /** What each settings write actually put on disk, per server (#2089). */ + lastPersistedSettings: LastPersistedSettings; + /** Re-applies one settings value to the live client, in full. */ + applyLiveServerSettings: (settings: InspectorServerSettings) => void; + updateServerSettings: ( + id: string, + settings: InspectorServerSettings, + ) => Promise; + refreshInitialConfig: () => void; + + // --- The OAuth recovery surface every command routes through (#2153). --- + handleCommandScopedAuthRecovery: OAuthRecovery["handleCommandScopedAuthRecovery"]; + runWithCommandAuthRecovery: OAuthRecovery["runWithCommandAuthRecovery"]; + runCommandInBackground: OAuthRecovery["runCommandInBackground"]; +} + +export interface ServerCommands { + onCallTool: ( + name: string, + args: Record, + runAsTask?: boolean, + ) => Promise; + onClearToolResult: () => void; + onToolsUiChange: (next: ToolsUiState) => void; + onGetPrompt: (name: string, args: Record) => Promise; + onReadResource: (uri: string) => Promise; + /** Reads a `resource_link`'s contents inline, returning them directly. */ + onReadResourceContents: ( + uri: string, + ) => Promise>["result"]>; + onSubscribeResource: (uri: string) => void; + onUnsubscribeResource: (uri: string) => void; + onCompleteArgument: ( + ref: + | { type: "ref/resource"; uri: string } + | { type: "ref/prompt"; name: string }, + argumentName: string, + argumentValue: string, + context: Record, + ) => Promise; + onCancelTask: (taskId: string) => Promise; + onCancelToolCall: () => void; + onClearCompletedTasks: () => void; + onSetLogLevel: (level: LoggingLevel) => void; + onSetModernLogLevel: (level: LoggingLevel | null) => void; + onRefreshTools: () => void; + onRefreshPrompts: () => void; + onRefreshResources: () => void; + onRefreshTasks: () => void; + onTogglePaginatedLists: (value: boolean) => void; + onLoadMoreTools: () => void; + onLoadMorePrompts: () => void; + onLoadMoreResources: () => void; +} + +/** + * Every command the screens issue against the live server: calling a tool, + * getting a prompt, reading and subscribing to a resource, completing an + * argument, cancelling a task or a call, setting a log level, refreshing or + * paging a list, and flipping the pagination mode. Lifted out of `App.tsx` by + * phase-2 step 4 of the decomposition (#2155, under #2129/#2126). + * + * It is one hook because every one of these routes through the same edge — + * the command-scoped auth recovery `useOAuthRecovery` publishes (#2153). A + * command that hits a lapsed authorization has to reach the *same* recovery, + * or two of them would prompt differently for one server. That dependency is + * also why this cluster cannot precede the OAuth one. + * + * The move is deliberately inert: nothing here changes behavior, #2095's + * per-server pagination override included. + */ +export function useServerCommands({ + sessionRef, + servers, + activeServerId, + activeServer, + inspectorClient, + connected, + capabilities, + tools, + panels, + selectedToolKey, + setToolsUi, + setUrlElicitationErrorDetails, + setCurrentLogLevel, + setModernLogLevel, + activeToolCallTaskIdRef, + clearCompletedTasks, + refreshTasks, + paginatedLists, + paginatedListsOverride, + toolsPagination, + promptsPagination, + resourcesPagination, + refreshTools, + refreshPrompts, + refreshResources, + refreshResourceTemplates, + clearToolsListChanged, + clearPromptsListChanged, + clearResourcesListChanged, + loadToolsPage, + loadPromptsPage, + loadResourcesPage, + lastPersistedSettings, + applyLiveServerSettings, + updateServerSettings, + refreshInitialConfig, + handleCommandScopedAuthRecovery, + runWithCommandAuthRecovery, + runCommandInBackground, +}: UseServerCommandsOptions): ServerCommands { + const { setToolCallState, setGetPromptState, setReadResourceState } = panels; + + const onCallTool = useCallback( + async ( + name: string, + args: Record, + runAsTask?: boolean, + ) => { + if (!inspectorClient) return; + const tool = tools.find((t: Tool) => t.name === name); + if (!tool) return; + // Route through the task pipeline when the caller asked to (or the tool + // requires it) — but only if the server advertises task tool calls. Per + // spec a tool's `taskSupport` is considered only when the server declares + // `tasks.requests.tools.call`, so without it we never task-augment (even a + // "required" tool, which then surfaces callTool's "requires task support" + // error). The created task shows up on the Tasks screen via the + // `requestorTaskUpdated` events callToolStream dispatches, and its live + // status/progress surface as toasts + progress bar. + // Legacy servers advertise task tool calls via + // `tasks.requests.tools.call`. Modern servers (SEP-2663) instead negotiate + // the `io.modelcontextprotocol/tasks` extension and are server-directed: + // task creation is decided per-request by the server, so declaring the + // extension on the call (which the task path does) is what makes a returned + // task handle legal ("unsolicited handles"). Either era routes the flagged + // call through the streaming task pipeline. + const serverSupportsTaskToolCalls = + !!capabilities?.tasks?.requests?.tools?.call || + inspectorClient.isTasksExtensionNegotiated(); + const asTask = + serverSupportsTaskToolCalls && + (runAsTask || tool.execution?.taskSupport === "required"); + // Drop any prior call's task id before starting; a task-augmented call + // repopulates it via the `toolCallTaskUpdated` listener below, an ordinary + // call leaves it cleared (#1455). + activeToolCallTaskIdRef.current = undefined; + setToolCallState({ status: "pending" }); + try { + // ToolsScreen types the args as `Record` (it accepts + // anything the user types into the schema form). `callTool` requires + // `Record` — narrow at the boundary instead of + // claiming the object is empty (which the previous `as Record` cast did, misleadingly). + const invocation = asTask + ? await inspectorClient.callToolStream( + tool, + args as Record, + undefined, + undefined, + { ttl: activeServer?.settings?.taskTtl || DEFAULT_TASK_TTL_MS }, + ) + : await inspectorClient.callTool( + tool, + args as Record, + ); + setToolCallState({ + status: invocation.success ? "ok" : "error", + result: invocation.result ?? undefined, + error: invocation.error, + }); + } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + setToolCallState(undefined); + if (activeServerId) { + await handleCommandScopedAuthRecovery(err, { + serverId: activeServerId, + source: "tool", + }); + } + return; + } + // The user cancelled the in-flight call (Cancel button → cancelToolCall). + // The cancellation notification was already sent to the server, so just + // clear the executing state — surfacing it as an error would read as a + // failure rather than the deliberate cancel it was (#1458). + if (err instanceof ToolCallCancelledError) { + setToolCallState(undefined); + notifications.show({ + title: "Tool call cancelled", + message: "A cancellation request was sent to the server.", + color: "gray", + autoClose: 3000, + }); + return; + } + // The server kept asking for a URL the user already completed this call, + // so callTool aborted to avoid an endless re-prompt loop. Surface that + // explicitly rather than as a generic failure. + if (err instanceof UrlElicitationLoopError) { + setToolCallState({ status: "error", error: err.message }); + notifications.show({ + autoClose: false, + title: "URL elicitation loop", + color: "yellow", + message: ( + + The server requested the same URL again after you completed it ( + {err.url}), so the call was cancelled to avoid an endless loop. + + ), + }); + return; + } + // A URLElicitationRequired (-32042) error that reaches here carried no + // elicitations (a non-spec response — the with-list case is handled and + // retried inside callTool). There's no URL to open, so surface a short + // toast that links to the raw error rather than a bare error panel. + const urlElicitations = getUrlElicitationsFromError(err); + if (urlElicitations !== null && urlElicitations.length === 0) { + const details = { + toolName: name, + details: formatErrorDetails(err), + }; + setToolCallState({ status: "error", error: errorMessage(err) }); + notifications.show({ + autoClose: false, + title: "URL elicitation required", + color: "yellow", + message: ( + setUrlElicitationErrorDetails(details)} + /> + ), + }); + return; + } + setToolCallState({ + status: "error", + error: errorMessage(err), + errorCode: errorCodeOf(err), + }); + } + }, + [ + inspectorClient, + tools, + activeServer, + capabilities, + activeServerId, + handleCommandScopedAuthRecovery, + activeToolCallTaskIdRef, + setToolCallState, + setUrlElicitationErrorDetails, + ], + ); + + const onClearToolResult = useCallback(() => { + setToolCallState(undefined); + }, [setToolCallState]); + + // Tools UI changes flow through here so selecting a *different* tool also + // drops the previous tool's result — the result panel renders `toolCallState` + // regardless of selection, so without this a stale result would linger under + // the newly-selected tool (which has no result of its own yet). Search and + // form edits keep `selectedToolKey` unchanged, so they leave the result be. + // Depends on `selectedToolKey` only (not the whole `toolsUi`), so a search + // keystroke doesn't churn the callback identity. + const onToolsUiChange = useCallback( + (next: ToolsUiState) => { + if (next.selectedToolKey !== selectedToolKey) { + setToolCallState(undefined); + } + setToolsUi(next); + }, + [selectedToolKey, setToolsUi, setToolCallState], + ); + const onGetPrompt = useCallback( + async (name: string, args: Record) => { + if (!inspectorClient) return; + // Tag the in-flight + final state with the prompt name so the + // PromptsScreen can guard against showing a stale result for a + // prompt the user has already navigated away from. + setGetPromptState({ status: "pending", promptName: name }); + try { + const invocation = await inspectorClient.getPrompt(name, args); + setGetPromptState({ + status: "ok", + promptName: name, + result: invocation.result, + }); + } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + setGetPromptState(undefined); + if (activeServerId) { + await handleCommandScopedAuthRecovery(err, { + serverId: activeServerId, + source: "prompt", + }); + } + return; + } + setGetPromptState({ + status: "error", + promptName: name, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [ + inspectorClient, + activeServerId, + handleCommandScopedAuthRecovery, + setGetPromptState, + ], + ); + + const onReadResource = useCallback( + async (uri: string) => { + if (!inspectorClient) return; + setReadResourceState({ status: "pending", uri }); + try { + const invocation = await inspectorClient.readResource(uri); + setReadResourceState({ + status: "ok", + uri, + result: invocation.result, + lastUpdated: invocation.timestamp, + }); + } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + setReadResourceState(undefined); + if (activeServerId) { + await handleCommandScopedAuthRecovery(err, { + serverId: activeServerId, + source: "resource", + }); + } + return; + } + setReadResourceState({ + status: "error", + uri, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [ + inspectorClient, + activeServerId, + handleCommandScopedAuthRecovery, + setReadResourceState, + ], + ); + + // Read-on-demand handler for `resource_link` blocks in a tool result. Unlike + // `onReadResource` (which drives the Resources screen's preview panel via + // shared state), this returns the contents directly so each ResourceLink can + // own and inline its own fetched content. + const onReadResourceContents = useCallback( + async (uri: string) => { + if (!inspectorClient) throw new Error("Client is not connected"); + const read = () => inspectorClient.readResource(uri); + try { + const invocation = await read(); + return invocation.result; + } catch (err) { + if (err instanceof AuthRecoveryRequiredError && activeServerId) { + const satisfied = await handleCommandScopedAuthRecovery(err, { + serverId: activeServerId, + source: "resource", + }); + if (satisfied) { + const retry = await read(); + return retry.result; + } + } + throw err; + } + }, + [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], + ); + + const onSubscribeResource = useCallback( + (uri: string) => { + if (!inspectorClient) return; + void inspectorClient.subscribeToResource(uri); + }, + [inspectorClient], + ); + + const onUnsubscribeResource = useCallback( + (uri: string) => { + if (!inspectorClient) return; + void inspectorClient.unsubscribeFromResource(uri); + }, + [inspectorClient], + ); + + const onCompleteArgument = useCallback( + async ( + ref: + | { type: "ref/resource"; uri: string } + | { type: "ref/prompt"; name: string }, + argumentName: string, + argumentValue: string, + context: Record, + ): Promise => { + if (!inspectorClient) return []; + const result = await runWithCommandAuthRecovery( + () => + inspectorClient.getCompletions( + ref, + argumentName, + argumentValue, + context, + ), + "tool", + ); + return result?.values ?? []; + }, + [inspectorClient, runWithCommandAuthRecovery], + ); + + const onCancelTask = useCallback( + async (taskId: string) => { + if (!inspectorClient) return; + try { + await runWithCommandAuthRecovery( + () => inspectorClient.cancelRequestorTask(taskId), + "tool", + ); + } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + return; + } + notifications.show({ + title: "Failed to cancel task", + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + } + }, + [inspectorClient, runWithCommandAuthRecovery], + ); + + // Cancel the in-flight tool call. A task-augmented call (run-as-task) has a + // server-side task, so cancel that via the tasks API (#1455) — the cancelled + // status then flows back through the managed task store and toasts, the same + // as cancelling from the Tasks screen. An ordinary call has no task, so abort + // its request: the SDK sends a `notifications/cancelled` to the server (the + // MCP cancellation flow) and the pending call rejects with a + // ToolCallCancelledError that `onCallTool` clears as a cancellation (#1458). + const onCancelToolCall = useCallback(() => { + if (!inspectorClient) return; + const taskId = activeToolCallTaskIdRef.current; + if (taskId) { + // Clear the ref before the call resolves so a rapid second Cancel click + // doesn't re-cancel the now-terminating task (which would surface a + // spurious "Failed to cancel task" toast). + activeToolCallTaskIdRef.current = undefined; + void onCancelTask(taskId); + return; + } + inspectorClient.cancelToolCall(); + }, [inspectorClient, onCancelTask, activeToolCallTaskIdRef]); + + const onClearCompletedTasks = useCallback(() => { + clearCompletedTasks(); + }, [clearCompletedTasks]); + + const onSetLogLevel = useCallback( + (level: LoggingLevel) => { + setCurrentLogLevel(level); + if (!inspectorClient) return; + // Nothing else records a `logging/setLevel` failure, and the optimistic + // level above already moved — so report it, or the selector would sit on + // a level the server never accepted with no explanation. + runCommandInBackground( + () => inspectorClient.setLoggingLevel(level), + "ambient", + "Failed to set log level", + ); + }, + [inspectorClient, runCommandInBackground, setCurrentLogLevel], + ); + + // Modern era (#1629): no request is sent — the client stores the level and + // stamps it on every subsequent request's `_meta`. `null` opts back out. + const onSetModernLogLevel = useCallback( + (level: LoggingLevel | null) => { + setModernLogLevel(level); + inspectorClient?.setModernLogLevel(level ?? undefined); + }, + [inspectorClient, setModernLogLevel], + ); + + // 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(() => { + 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(); + runCommandInBackground(() => toolsPagination.onRefresh(), "ambient"); + } else { + runCommandInBackground(() => refreshTools(), "ambient"); + } + }, [ + paginatedLists, + toolsPagination, + refreshTools, + clearToolsListChanged, + runCommandInBackground, + ]); + const onRefreshPrompts = useCallback(() => { + if (paginatedLists) { + clearPromptsListChanged(); + runCommandInBackground(() => promptsPagination.onRefresh(), "ambient"); + } else { + runCommandInBackground(() => refreshPrompts(), "ambient"); + } + }, [ + paginatedLists, + promptsPagination, + refreshPrompts, + clearPromptsListChanged, + runCommandInBackground, + ]); + const onRefreshResources = useCallback(() => { + if (paginatedLists) { + clearResourcesListChanged(); + runCommandInBackground(() => resourcesPagination.onRefresh(), "ambient"); + // Resource templates always use the managed (aggregate) path. + runCommandInBackground(() => refreshResourceTemplates(), "ambient"); + } else { + runCommandInBackground(async () => { + await refreshResources(); + await refreshResourceTemplates(); + }, "ambient"); + } + }, [ + paginatedLists, + resourcesPagination, + refreshResources, + refreshResourceTemplates, + clearResourcesListChanged, + runCommandInBackground, + ]); + // 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) => { + const current = servers.find((s) => s.id === activeServerId); + if (!current || activeServerId === undefined) return; + // Recorded against this server rather than app-wide, so it survives a + // switch away and back (#2095). Ordered after the id guard because the + // record is keyed by that id; with no active server there is nothing the + // toggle could have been flipped for. + paginatedListsOverride.record(activeServerId, value); + // Not `current.settings` directly: that entry only advances on a + // successful list read, so once one has failed it describes disk as it + // was *before* the writes made since. Build on the last write known to + // have landed while that is the fresher account (#2089). + const next: InspectorServerSettings = { + ...(lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS), + 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) { + runCommandInBackground(() => loadToolsPage(undefined), "ambient"); + runCommandInBackground(() => loadPromptsPage(undefined), "ambient"); + runCommandInBackground(() => loadResourcesPage(undefined), "ambient"); + } else { + runCommandInBackground(() => refreshTools(), "ambient"); + runCommandInBackground(() => refreshPrompts(), "ambient"); + runCommandInBackground(() => refreshResources(), "ambient"); + } + } + // Refreshed like every other secret-store mutation: this resends the + // server's rehydrated secrets, so it can trigger the pending + // plaintext-to-encrypted upgrade even though the user only toggled + // pagination (#1950 review r22). + // Announced before the request goes out, so two toggles in flight at once + // are ordered by when they were issued rather than by which one's list + // reload finished first (#2089). + const write = lastPersistedSettings.begin(activeServerId); + // This value is on disk now. Remember it as the rollback baseline for + // whatever is written next, since the `servers` entry it was derived + // from will keep describing the old value if the reload behind this + // write — or any later one — fails (#2089). + // + // Re-apply it when this write is the settled one: an overlapping toggle + // that failed *first* rolled the UI and the live client back to a + // baseline this write has since replaced, and if the list read behind + // this write failed too, nothing else would ever correct them. Through + // the *current* client, not this continuation's closure: a reconnect to + // the same server passes the id check while the captured instance is + // already destroyed. + const settlePaginationWrite = () => { + const settled = write.landed(next); + if (!settled) return; + // The override is keyed by server, so it is re-applied whatever is + // active now — it is this server's value and is only ever displayed + // while this server is the active one. The live client is not: it + // belongs to whichever server is connected (#2095). + paginatedListsOverride.record( + activeServerId, + next.paginatedLists ?? false, + ); + if (sessionRef.current.activeServerId === activeServerId) { + applyLiveServerSettings(next); + } + }; + void refreshingPersist(updateServerSettings, refreshInitialConfig)( + activeServerId, + next, + ) + .then(settlePaginationWrite) + .catch((err: unknown) => { + // A `ServerListReloadError` means the PUT landed and only reading the + // list back failed, so the new setting IS on disk (#1914). That is a + // landed write, not a failed one: rolling back would put the UI and + // the live client on the *old* value and contradict disk. Settle it + // exactly as the success path does and report only the failed reload. + if (err instanceof ServerListReloadError) { + settlePaginationWrite(); + notifications.show({ + title: + "Pagination setting saved, but the server list did not reload", + message: err.message, + color: "red", + }); + return; + } + // This write is over and never reached disk, so it stops counting as + // in flight: an earlier write still running is the settled state once + // it lands, and is what re-applies the UI this rollback is about to + // set (#2089). + write.failed(); + // Persist failed: revert the optimistic override 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). + // + // The baseline is resolved *here*, not captured when this write was + // issued: another toggle can land in between, and its value is what + // disk holds by the time this one fails. The override is set to that + // baseline rather than cleared, because clearing it falls back to + // `persistedPaginatedLists` — read from a `servers` entry that may be + // stale, showing the same wrong value from the other side (#2089). + // + // The override is recorded whatever is active by the time this + // rejection arrives: it is keyed by this write's server and is only + // ever displayed while that server is the active one, so a switch in + // between costs nothing and dropping it would leave the stale entry + // to answer for A the next time it comes back (#2095). + // + // The live client is the half that stays gated — it belongs to + // whichever server is connected now, so pushing this server's value + // into it after a switch would apply it to another one. It is taken + // from the ref for the same reason the success path does: a reconnect + // to the same server passes the id check while this continuation's + // captured instance is already destroyed. + const baseline = + lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS; + paginatedListsOverride.record( + activeServerId, + baseline.paginatedLists ?? false, + ); + if (sessionRef.current.activeServerId === activeServerId) { + applyLiveServerSettings(baseline); + } + notifications.show({ + title: "Failed to save pagination setting", + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + }); + }, + [ + sessionRef, + servers, + activeServerId, + lastPersistedSettings, + paginatedListsOverride, + applyLiveServerSettings, + inspectorClient, + updateServerSettings, + refreshInitialConfig, + connected, + loadToolsPage, + loadPromptsPage, + loadResourcesPage, + refreshTools, + refreshPrompts, + refreshResources, + runCommandInBackground, + ], + ); + // 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( + () => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"), + [toolsPagination, runCommandInBackground], + ); + const onLoadMorePrompts = useCallback( + () => + runCommandInBackground(() => promptsPagination.onLoadMore(), "ambient"), + [promptsPagination, runCommandInBackground], + ); + const onLoadMoreResources = useCallback( + () => + runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"), + [resourcesPagination, runCommandInBackground], + ); + const onRefreshTasks = useCallback(() => { + runCommandInBackground( + () => refreshTasks(), + "ambient", + "Failed to refresh tasks", + ); + }, [refreshTasks, runCommandInBackground]); + + return { + onCallTool, + onClearToolResult, + onToolsUiChange, + onGetPrompt, + onReadResource, + onReadResourceContents, + onSubscribeResource, + onUnsubscribeResource, + onCompleteArgument, + onCancelTask, + onCancelToolCall, + onClearCompletedTasks, + onSetLogLevel, + onSetModernLogLevel, + onRefreshTools, + onRefreshPrompts, + onRefreshResources, + onRefreshTasks, + onTogglePaginatedLists, + onLoadMoreTools, + onLoadMorePrompts, + onLoadMoreResources, + }; +} From 5c7c5184cf4c61e27dd27fe57adc8000bbfcb18c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 27 Aug 2026 20:35:33 -0400 Subject: [PATCH 2/4] chore(web): address Copilot review on #2155 Give the test's paginated-list fixture a precise return type instead of an unjustified double cast. The cast was hiding that the fixture omitted `error`, so an interface change would not have been type-checked there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall --- clients/web/src/hooks/useServerCommands.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/clients/web/src/hooks/useServerCommands.test.tsx b/clients/web/src/hooks/useServerCommands.test.tsx index 226c8a297..6bb5ad934 100644 --- a/clients/web/src/hooks/useServerCommands.test.tsx +++ b/clients/web/src/hooks/useServerCommands.test.tsx @@ -110,16 +110,19 @@ const authError = () => reason: "unauthorized", }); -function pagination(over: Partial> = {}) { +function pagination( + over: Partial> = {}, +): PaginatedListModel { return { - items: [] as T[], + items: [], paginated: false, canLoadMore: false, loadedPages: 1, + error: null, onRefresh: vi.fn().mockResolvedValue(undefined), onLoadMore: vi.fn().mockResolvedValue(undefined), ...over, - } as unknown as PaginatedListModel; + }; } interface HarnessProps { From f7fe4bc9e9dc6cf5bfa052b289427d874637c1eb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 27 Aug 2026 20:50:59 -0400 Subject: [PATCH 3/4] test(web): make the harness's recovery wrapper actually recover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 2. The stand-in `runWithCommandAuthRecovery` was a bare pass-through while its comment claimed it reproduced the real one, so `onCompleteArgument` and `onCancelTask` — whose only auth handling *is* that wrapper — would have passed every test here while bypassing the shared recovery entirely. That routing is the integration this extraction exists to preserve. The stand-in now matches `useOAuthRecovery`'s: it catches an `AuthRecoveryRequiredError`, hands it to `handleCommandScopedAuthRecovery` with the call site's `source` and a `retryOperation`, retries once when the recovery reports satisfied, and rethrows anything else. `runCommandInBackground` is built on it, as the real one is. Three tests pin the source and the retry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall --- .../web/src/hooks/useServerCommands.test.tsx | 132 ++++++++++++++++-- 1 file changed, 118 insertions(+), 14 deletions(-) diff --git a/clients/web/src/hooks/useServerCommands.test.tsx b/clients/web/src/hooks/useServerCommands.test.tsx index 6bb5ad934..8704d1b6b 100644 --- a/clients/web/src/hooks/useServerCommands.test.tsx +++ b/clients/web/src/hooks/useServerCommands.test.tsx @@ -25,6 +25,7 @@ import { type ServerCommands, } from "./useServerCommands"; import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen"; +import type { StepUpSource } from "../utils/stepUp"; // --- Module doubles --------------------------------------------------------- // Only the toast layer, which every failure path reaches. The client is a bare @@ -207,11 +208,17 @@ function harness(initial: HarnessProps = {}): Harness { }; /** - * The real wrappers are thin: `runWithCommandAuthRecovery` awaits the - * operation and retries once through the recovery, and - * `runCommandInBackground` is its fire-and-forget form. Reproducing that - * shape here (rather than stubbing them as pass-throughs) is what makes the - * commands' own auth branches reachable. + * The two recovery wrappers, reimplemented to match `useOAuthRecovery`'s + * rather than stubbed as pass-throughs. + * + * That distinction is the point, not tidiness. A pass-through would let + * `onCompleteArgument` and `onCancelTask` pass every test here while + * bypassing the shared recovery entirely — and routing *through* it is the + * integration this extraction exists to preserve. So the stand-in keeps the + * three behaviors a command depends on: it catches an + * `AuthRecoveryRequiredError`, hands it to `handleCommandScopedAuthRecovery` + * with that call site's `source` and a `retryOperation`, retries once when + * the recovery reports satisfied, and rethrows anything else. */ function Probe({ p }: { p: HarnessProps }) { const servers = p.servers ?? []; @@ -228,7 +235,24 @@ function harness(initial: HarnessProps = {}): Harness { | undefined) ?? s.handleCommandScopedAuthRecovery; const runWithCommandAuthRecovery = async ( operation: () => Promise, - ): Promise => operation(); + source: StepUpSource, + ): Promise => { + const serverId = p.activeServerId; + if (!p.client || serverId === undefined) return operation(); + try { + return await operation(); + } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + const satisfied = await handleCommandScopedAuthRecovery(err, { + serverId, + source, + retryOperation: operation, + }); + return satisfied ? operation() : undefined; + } + throw err; + } + }; latest = useServerCommands({ sessionRef, servers, @@ -273,13 +297,21 @@ function harness(initial: HarnessProps = {}): Harness { refreshInitialConfig: s.refreshInitialConfig, handleCommandScopedAuthRecovery, runWithCommandAuthRecovery, - runCommandInBackground: (operation, _source, errorTitle) => { - void operation().catch((err: unknown) => { - s.runCommandInBackgroundErrors.push(err); - if (errorTitle) { - notificationsMock.show({ title: errorTitle }); - } - }); + // The real one is `runWithCommandAuthRecovery(...).catch(...)`, so a + // background command reaches the recovery on the same terms an awaited + // one does; only the reporting is its own. + runCommandInBackground: (operation, source, errorTitle) => { + void runWithCommandAuthRecovery(operation, source).catch( + (err: unknown) => { + s.runCommandInBackgroundErrors.push(err); + if (errorTitle) { + notificationsMock.show({ + title: errorTitle, + message: err instanceof Error ? err.message : String(err), + }); + } + }, + ); }, }); return null; @@ -854,6 +886,51 @@ describe("subscriptions and completion", () => { ).resolves.toEqual([]); }); + it("routes a lapsed authorization through the shared recovery, and retries", async () => { + const recover = vi.fn().mockResolvedValue(true); + const getCompletions = vi + .fn() + .mockRejectedValueOnce(authError()) + .mockResolvedValue({ values: ["after-reauth"] }); + const h = harness({ + client: client({ getCompletions }), + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await expect( + h + .api() + .onCompleteArgument({ type: "ref/prompt", name: "p" }, "a", "", {}), + ).resolves.toEqual(["after-reauth"]); + // The source is what decides which panel a step-up failure is reported + // into, so it is pinned rather than merely "some recovery ran". + expect(recover).toHaveBeenCalledWith( + expect.any(AuthRecoveryRequiredError), + { + serverId: "a", + source: "tool", + retryOperation: expect.any(Function), + }, + ); + expect(getCompletions).toHaveBeenCalledTimes(2); + }); + + it("returns no completions when the recovery was not satisfied", async () => { + const recover = vi.fn().mockResolvedValue(false); + const h = harness({ + client: client({ + getCompletions: vi.fn().mockRejectedValue(authError()), + }), + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await expect( + h + .api() + .onCompleteArgument({ type: "ref/prompt", name: "p" }, "a", "", {}), + ).resolves.toEqual([]); + }); + it("returns no completions when the result carries none", async () => { const h = harness({ client: client({ getCompletions: vi.fn().mockResolvedValue({}) }), @@ -880,7 +957,34 @@ describe("cancellation and tasks", () => { expect(notificationsMock.show).not.toHaveBeenCalled(); }); - it("stays silent when the cancel raised a recovery", async () => { + it("routes a lapsed authorization through the shared recovery, and retries", async () => { + const recover = vi.fn().mockResolvedValue(true); + const cancelRequestorTask = vi + .fn() + .mockRejectedValueOnce(authError()) + .mockResolvedValue(undefined); + const h = harness({ + client: client({ cancelRequestorTask }), + activeServerId: "a", + recovery: { handleCommandScopedAuthRecovery: recover }, + }); + await act(async () => h.api().onCancelTask("t1")); + expect(recover).toHaveBeenCalledWith( + expect.any(AuthRecoveryRequiredError), + { + serverId: "a", + source: "tool", + retryOperation: expect.any(Function), + }, + ); + expect(cancelRequestorTask).toHaveBeenCalledTimes(2); + // The recovery owns the prompt, so the cancel itself says nothing. + expect(notificationsMock.show).not.toHaveBeenCalled(); + }); + + it("stays silent when a recovery it could not run rethrows", async () => { + // No active server, so the wrapper cannot recover and the error reaches + // the handler's own catch — which swallows it deliberately. const h = harness({ client: client({ cancelRequestorTask: vi.fn().mockRejectedValue(authError()), From 71cd1130eee051e3c908287eb388c63d0cd7acac Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 27 Aug 2026 21:02:40 -0400 Subject: [PATCH 4/4] chore(web): fix a duplicated word in a moved comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 3, from its suppressed set: "a paginated paginated fetch". The line came across verbatim with `onLoadMoreTools`, so the typo predates this PR — fixed here since the comment is the thing being moved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall --- clients/web/src/hooks/useServerCommands.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/web/src/hooks/useServerCommands.tsx b/clients/web/src/hooks/useServerCommands.tsx index 6db8674d6..f4cb49f14 100644 --- a/clients/web/src/hooks/useServerCommands.tsx +++ b/clients/web/src/hooks/useServerCommands.tsx @@ -891,8 +891,8 @@ export function useServerCommands({ runCommandInBackground, ], ); - // 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). + // Wrap Load-next-page in ambient auth recovery too, so a paginated fetch + // that hits a 401 recovers like the all-pages path (#1721). const onLoadMoreTools = useCallback( () => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"), [toolsPagination, runCommandInBackground],