import { Card, Flex, Stack, Text } from "@mantine/core"; import type { MalformedListItem } from "@inspector/core/mcp"; import type { CallToolResult, ReadResourceResult, Tool, } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "@inspector/core/mcp/types.js"; import { ToolControls } from "../../groups/ToolControls/ToolControls"; import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ToolDetailPanel, type ToolProgress, } from "../../groups/ToolDetailPanel/ToolDetailPanel"; import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; import { ToolCallErrorPanel } from "../../groups/ToolResultPanel/ToolCallErrorPanel"; import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils"; import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; import { findToolByRowKey } from "../../../utils/toolUtils"; export interface ToolCallState { status: "idle" | "pending" | "ok" | "error"; result?: CallToolResult; error?: string; /** * JSON-RPC error code when the call REJECTED (a thrown `ProtocolError`) rather * than resolving a result. SDK v2 rejects an unknown-tool call with `-32602` * instead of returning an `isError` result, so this drives the distinct * "Unknown Tool" rendering in the error panel (#1632). */ errorCode?: number; progress?: ToolProgress; } // Selection, form values, and sidebar search — controlled by the parent (App) // as one object so they persist across tab navigation within a live session; // the screen unmounts on tab switch, so local state would be lost (#1414/#1417). export interface ToolsUiState { /** * The selected row's `toolRowKey` — its position in the list plus its name, * not the name alone. A `tools/list` may repeat a name, and identifying the * selection by name highlighted every copy while the detail panel always * resolved the first, so the later copies could not be inspected (#2001). * The protocol name a `tools/call` sends still comes from the resolved tool. */ selectedToolKey?: string; formValues: Record; search: string; // Screen-level "Run as task" toggle, shared across tools (not per-tool): // selecting a different tool keeps the current value. Persists across tab // navigation like the rest of the UI state, and is only honored for the // selected tool when its `execution.taskSupport` is "optional" (a "required" // tool is always run as a task, "forbidden" never) — see ToolDetailPanel. runAsTask: boolean; } export interface ToolsScreenProps { tools: Tool[]; /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` * annotations (SEP-2243), shown in the sidebar with the reason (#1632). */ excludedTools?: ExcludedTool[]; /** * Entries dropped from a list result as malformed, across every list method. * Each list panel filters for its own and warns about them (#1909). */ malformedListItems?: MalformedListItem[]; callState?: ToolCallState; ui: ToolsUiState; listChanged: boolean; /** Whether the connected server advertises task-augmented tool calls. */ serverSupportsTaskToolCalls: boolean; /** Modern (SEP-2663) tasks extension negotiated — "Run as task" is offered * for any tool (server-directed task creation). */ modernTasks?: boolean; onUiChange: (next: ToolsUiState) => void; onRefreshList: () => void; /** A failed list load, rendered above the sidebar list (#1953). */ loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; onCallTool: ( name: string, args: Record, runAsTask?: boolean, ) => void; onCancelCall?: () => void; onClearResult?: () => void; /** * Read-on-demand handler for `resource_link` blocks in a tool result. * Passed through to the result panel so links can inline their contents. */ onReadResource?: (uri: string) => Promise; } // Caps the detail/result columns at the screen's available height: full // viewport minus the app-shell header and the screen's top+bottom xl padding, // leaving the bottom margin the overflow used to eat. const SCROLL_MAX_HEIGHT = "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px) - var(--mantine-spacing-xl) * 2)"; // No `align` override: children stretch to the row's full height, giving each // column pane a definite height. That definite height is what lets a column's // inner ScrollArea know how much it can shrink into (a bare `mah` doesn't — // see the Prompts/Resources preview panes this mirrors). const ScreenLayout = Flex.withProps({ variant: "screen", h: "calc(100dvh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))", gap: "md", p: "xl", }); const Sidebar = Stack.withProps({ // Widened from 340 to comfortably fit the pagination controls // (Load-next-page button + status) without cramping list entries (#1721). w: 360, flex: "0 0 auto", }); // `sidebar` variant makes the card a full-height flex column capped at the // screen height, so ToolControls' list fills the card and scrolls internally // once it overflows (matching the Resources sidebar). (#1417) const SidebarCard = Card.withProps({ withBorder: true, padding: "lg", variant: "sidebar", }); // Column wrapper: stretches to the screen's available height (capped by the // consumer-set `mah`) so the card inside has a definite height to shrink into. const ContentPane = Flex.withProps({ flex: 1, miw: 0, direction: "column", align: "stretch", }); // Detail/result column card: `variant="preview"` (overflow: hidden) lets the // panel's inner ScrollArea take over scrolling instead of the card bleeding // past the viewport. Sizes to content when short, caps at the pane when tall. const ContentCard = Card.withProps({ withBorder: true, padding: "lg", variant: "preview", }); // Full-height card for the empty placeholder (used with `flex={1}`) so it fills // the screen height like the Prompts/Resources placeholders, rather than // shrinking to its text. The result/detail states keep the content-sized // `ContentCard` (their inner ScrollArea handles overflow). const DetailCard = Card.withProps({ withBorder: true, padding: "lg", }); const EmptyState = Text.withProps({ c: "dimmed", ta: "center", py: "xl", }); export function ToolsScreen({ tools, excludedTools, malformedListItems, callState, ui, listChanged, serverSupportsTaskToolCalls, modernTasks = false, onUiChange, onRefreshList, loadError, pagination, onCallTool, onCancelCall, onClearResult, onReadResource, }: ToolsScreenProps) { const { selectedToolKey, formValues, search } = ui; const selectedTool = findToolByRowKey(tools, selectedToolKey); const isExecuting = callState?.status === "pending"; const handleSelectTool = (key: string) => { // Seed the form with the tool's schema defaults so default-only fields the // user never edits are still sent on execute (the form shows defaults via // resolveValue, but onChange only writes edited fields). const tool = findToolByRowKey(tools, key); // `key` always comes from the rendered tools list (ToolControls only emits // keys it computed from the tools it was given), so the lookup never misses; // the empty-object fallback is an unreachable defensive default. let nextFormValues: Record = {}; /* v8 ignore next -- unreachable: onSelectTool always keys a tool in the list */ if (tool) nextFormValues = collectSchemaDefaults( toFormSchema(tool.inputSchema) ?? {}, ); onUiChange({ ...ui, selectedToolKey: key, formValues: nextFormValues }); }; return ( onUiChange({ ...ui, search: value })} onSelectTool={handleSelectTool} /> {callState?.result ? ( // Results replace the input form while present, and the panel's top-left // X dismisses them back to the form (#1661) — the Prompts screen pattern. // `formValues` live in the lifted UI state, so the form is restored // intact for a re-run. A call in flight sets a `pending` state with no // `result` (App.tsx), so the executing form (progress + cancel) shows // until the result lands. {/* Fill the pane's full height only when the result renders a "Resource Links" box, so that box can expand into the available space and scroll within. Plain text/image/error results keep the content-sized card (matching the input-form state) instead of reserving a tall empty card. */} onClearResult?.()} onReadResource={onReadResource} /> ) : callState?.status === "error" && callState.error ? ( // A thrown rejection (no result) — e.g. SDK v2's `-32602` unknown-tool // reject, which no longer arrives as an `isError` CallToolResult. The X // dismisses back to the form, like a result (#1632). onClearResult?.()} /> ) : selectedTool ? ( onUiChange({ ...ui, runAsTask: value }) } onFormChange={(values) => onUiChange({ ...ui, formValues: values }) } onExecute={(runAsTask) => onCallTool(selectedTool.name, formValues, runAsTask) } onCancel={() => onCancelCall?.()} /> ) : ( // Empty placeholder fills the full screen height (like Prompts/Resources) // rather than shrinking to its text. Select a tool to view details )} ); }