From 92ecfcbd6cc30a40a05e9dca5f457a321c058163 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 18 Apr 2026 16:57:10 -0400 Subject: [PATCH 1/5] Phase 2: Refactor Tools cluster to use MCP SDK types Replace ad hoc local types with MCP SDK schema types across the Tools group components and ToolsScreen, per the v2 UX interfaces plan. - ToolListItem: accept `tool: Tool` instead of flat `name`/`title` - ToolControls: accept `Tool[]` + `selectedName` instead of `ToolListItemProps[]` - ToolDetailPanel: accept `tool: Tool` instead of flat props; use SDK `ToolAnnotations` (readOnlyHint/destructiveHint/idempotentHint/openWorldHint); use `ProgressNotification['params']` for progress - ToolResultPanel: accept `CallToolResult` instead of `ResultContentItem[]` - ToolsScreen: accept `Tool[]` + `selectedToolName` + `ToolCallState`; derive selected tool via `tools.find()`; own `formValues` state locally - Delete local `ToolAnnotations`, `ResultContentItem` types - Update all stories with real SDK `Tool` fixtures - Update ConnectedView ToolsActive story Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ToolControls/ToolControls.stories.tsx | 20 +- .../groups/ToolControls/ToolControls.tsx | 13 +- .../ToolDetailPanel.stories.tsx | 124 ++++++------ .../ToolDetailPanel/ToolDetailPanel.tsx | 82 +++----- .../ToolListItem/ToolListItem.stories.tsx | 24 ++- .../groups/ToolListItem/ToolListItem.tsx | 12 +- .../ToolResultPanel.stories.tsx | 83 ++++++--- .../ToolResultPanel/ToolResultPanel.tsx | 46 ++--- .../ToolsScreen/ToolsScreen.stories.tsx | 176 ++++++++---------- .../screens/ToolsScreen/ToolsScreen.tsx | 59 ++++-- .../ConnectedView/ConnectedView.stories.tsx | 162 +++++++--------- 11 files changed, 389 insertions(+), 412 deletions(-) diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx index 9a5cc96a..87dd8719 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { fn } from "storybook/test"; import { ToolControls } from "./ToolControls"; @@ -15,22 +16,20 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const sampleTools = [ +const sampleTools: Tool[] = [ { name: "send_message", title: "Send Message", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "create_record", title: "Create Record", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, - { name: "delete_records", selected: false, onClick: fn() }, - { name: "list_users", selected: false, onClick: fn() }, - { name: "batch_process", selected: false, onClick: fn() }, + { name: "delete_records", inputSchema: { type: "object" } }, + { name: "list_users", inputSchema: { type: "object" } }, + { name: "batch_process", inputSchema: { type: "object" } }, ]; export const Default: Story = { @@ -41,9 +40,8 @@ export const Default: Story = { export const WithSelection: Story = { args: { - tools: sampleTools.map((t) => - t.name === "create_record" ? { ...t, selected: true } : t, - ), + tools: sampleTools, + selectedName: "create_record", }, }; diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 33db7738..cc533276 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ToolListItem } from "../ToolListItem/ToolListItem"; -import type { ToolListItemProps } from "../ToolListItem/ToolListItem"; export interface ToolControlsProps { - tools: ToolListItemProps[]; + tools: Tool[]; + selectedName?: string; listChanged: boolean; onRefreshList: () => void; onSelectTool: (name: string) => void; @@ -17,6 +18,7 @@ function listMaxHeight(): string { export function ToolControls({ tools, + selectedName, listChanged, onRefreshList, onSelectTool, @@ -47,8 +49,11 @@ export function ToolControls({ {filteredTools.map((tool) => ( onSelectTool(tool.name)} + tool={tool} + selected={tool.name === selectedName} + onClick={() => { + if (tool.name !== selectedName) onSelectTool(tool.name); + }} /> ))} diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx index 559a0a22..9760b8c1 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { fn } from "storybook/test"; import { ToolDetailPanel } from "./ToolDetailPanel"; @@ -17,83 +18,102 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const sendMessageTool: Tool = { + name: "send_message", + inputSchema: { + type: "object", + properties: { + message: { type: "string", description: "The message to send" }, + }, + required: ["message"], + }, +}; + +const createRecordTool: Tool = { + name: "create_record", + description: "Creates a new record with the given parameters", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Record title" }, + count: { type: "number", description: "Number of items" }, + enabled: { + type: "boolean", + description: "Whether the record is active", + }, + }, + required: ["title"], + }, +}; + +const deleteRecordsTool: Tool = { + name: "delete_records", + description: "Deletes records matching the given criteria", + annotations: { + readOnlyHint: true, + destructiveHint: true, + }, + inputSchema: { + type: "object", + properties: { + pattern: { type: "string", description: "Pattern to match records" }, + }, + }, +}; + +const longQueryTool: Tool = { + name: "long_query", + description: "Runs a long database query", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "SQL query to execute" }, + }, + }, +}; + +const batchProcessTool: Tool = { + name: "batch_process", + description: "Processes items in batch", + inputSchema: { + type: "object", + properties: { + batchSize: { type: "number", description: "Number of items per batch" }, + }, + }, +}; + export const SimpleStringParam: Story = { args: { - name: "send_message", - schema: { - type: "object", - properties: { - message: { type: "string", description: "The message to send" }, - }, - required: ["message"], - }, + tool: sendMessageTool, }, }; export const MultipleParams: Story = { args: { - name: "create_record", - description: "Creates a new record with the given parameters", - schema: { - type: "object", - properties: { - title: { type: "string", description: "Record title" }, - count: { type: "number", description: "Number of items" }, - enabled: { - type: "boolean", - description: "Whether the record is active", - }, - }, - required: ["title"], - }, + tool: createRecordTool, }, }; export const WithAnnotations: Story = { args: { - name: "delete_records", - description: "Deletes records matching the given criteria", - annotations: { - audience: "admin", - readOnly: true, - destructive: true, - }, - schema: { - type: "object", - properties: { - pattern: { type: "string", description: "Pattern to match records" }, - }, - }, + tool: deleteRecordsTool, }, }; export const Executing: Story = { args: { - name: "long_query", - description: "Runs a long database query", + tool: longQueryTool, isExecuting: true, - schema: { - type: "object", - properties: { - query: { type: "string", description: "SQL query to execute" }, - }, - }, formValues: { query: "SELECT * FROM users" }, }, }; export const WithProgress: Story = { args: { - name: "batch_process", - description: "Processes items in batch", + tool: batchProcessTool, isExecuting: true, - progress: { percent: 60, description: "Processing step 3 of 5" }, - schema: { - type: "object", - properties: { - batchSize: { type: "number", description: "Number of items per batch" }, - }, - }, + progress: { progress: 3, total: 5, message: "Processing step 3 of 5" }, formValues: { batchSize: 100 }, }, }; diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index 2673d960..72b5f1c3 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -1,26 +1,21 @@ import { Button, Divider, Group, Stack, Text } from "@mantine/core"; +import type { + ProgressNotification, + Tool, + ToolAnnotations, +} from "@modelcontextprotocol/sdk/types.js"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; import { SchemaForm } from "../SchemaForm/SchemaForm"; -import type { JsonSchema } from "../SchemaForm/SchemaForm"; - -export interface ToolAnnotations { - audience?: string; - readOnly?: boolean; - destructive?: boolean; - longRunning?: boolean; - hints?: string; -} export interface ToolDetailPanelProps { - name: string; - title?: string; - description?: string; - annotations?: ToolAnnotations; - schema: JsonSchema; + tool: Tool; formValues: Record; isExecuting: boolean; - progress?: { percent: number; description?: string }; + progress?: Pick< + ProgressNotification["params"], + "progress" | "total" | "message" + >; onFormChange: (values: Record) => void; onExecute: () => void; onCancel: () => void; @@ -32,19 +27,13 @@ const ToolTitle = Text.withProps({ truncate: "end", }); -const HintsText = Text.withProps({ - size: "xs", - c: "dimmed", - fs: "italic", -}); - const DescriptionText = Text.withProps({ size: "sm", c: "dimmed", }); const CancelButton = Button.withProps({ - variant: "light", + variant: "subtle", color: "red", }); @@ -55,20 +44,16 @@ function resolveTitle(name: string, title?: string): string { function hasAnyAnnotation(annotations?: ToolAnnotations): boolean { return !!( annotations && - (annotations.audience || - annotations.readOnly || - annotations.destructive || - annotations.longRunning || - annotations.hints) + (annotations.title || + annotations.readOnlyHint || + annotations.destructiveHint || + annotations.idempotentHint || + annotations.openWorldHint) ); } export function ToolDetailPanel({ - name, - title, - description, - annotations, - schema, + tool, formValues, isExecuting, progress, @@ -76,29 +61,25 @@ export function ToolDetailPanel({ onExecute, onCancel, }: ToolDetailPanelProps) { + const { name, title, description, annotations, inputSchema } = tool; + return ( {resolveTitle(name, title)} {hasAnyAnnotation(annotations) && annotations && ( - {annotations.audience && ( - - )} - {annotations.readOnly && ( + {annotations.readOnlyHint && ( )} - {annotations.destructive && ( + {annotations.destructiveHint && ( )} - {annotations.longRunning && ( - + {annotations.idempotentHint && ( + + )} + {annotations.openWorldHint && ( + )} - {annotations.hints && {annotations.hints}} )} @@ -107,20 +88,13 @@ export function ToolDetailPanel({ - {progress && ( - - )} + {progress && } {isExecuting && Cancel} diff --git a/clients/web/src/components/groups/ToolListItem/ToolListItem.stories.tsx b/clients/web/src/components/groups/ToolListItem/ToolListItem.stories.tsx index cc3c03b5..86936a46 100644 --- a/clients/web/src/components/groups/ToolListItem/ToolListItem.stories.tsx +++ b/clients/web/src/components/groups/ToolListItem/ToolListItem.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { fn } from "storybook/test"; import { ToolListItem } from "./ToolListItem"; @@ -13,31 +14,44 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const weatherTool: Tool = { + name: "get_weather", + inputSchema: { type: "object" }, +}; + +const weatherToolWithTitle: Tool = { + name: "get_weather", + title: "Get Weather", + inputSchema: { type: "object" }, +}; + export const Default: Story = { args: { - name: "get_weather", + tool: weatherTool, selected: false, }, }; export const Selected: Story = { args: { - name: "get_weather", + tool: weatherTool, selected: true, }, }; export const WithTitle: Story = { args: { - name: "get_weather", - title: "Get Weather", + tool: weatherToolWithTitle, selected: false, }, }; export const LongName: Story = { args: { - name: "this_is_a_very_long_tool_name_that_might_need_to_wrap_across_multiple_lines_in_the_ui", + tool: { + name: "this_is_a_very_long_tool_name_that_might_need_to_wrap_across_multiple_lines_in_the_ui", + inputSchema: { type: "object" }, + }, selected: false, }, }; diff --git a/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx b/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx index bbe8c427..fa4c86f9 100644 --- a/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx +++ b/clients/web/src/components/groups/ToolListItem/ToolListItem.tsx @@ -1,8 +1,8 @@ import { Stack, Text, UnstyledButton } from "@mantine/core"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; export interface ToolListItemProps { - name: string; - title?: string; + tool: Tool; selected: boolean; onClick: () => void; } @@ -22,12 +22,8 @@ function resolveLabel(name: string, title?: string): string { return title ?? name; } -export function ToolListItem({ - name, - title, - selected, - onClick, -}: ToolListItemProps) { +export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) { + const { name, title } = tool; return ( = { export default meta; type Story = StoryObj; +const emptyResult: CallToolResult = { + content: [], +}; + +const textResult: CallToolResult = { + content: [ + { + type: "text", + text: "The current weather in San Francisco is 65°F and sunny.", + }, + ], +}; + +const jsonResult: CallToolResult = { + content: [ + { + type: "text", + text: '{"temperature":65,"unit":"fahrenheit","condition":"sunny","city":"San Francisco"}', + }, + ], +}; + +const imageResult: CallToolResult = { + content: [ + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + mimeType: "image/png", + }, + ], +}; + +const mixedResult: CallToolResult = { + content: [ + { + type: "text", + text: "Here is the generated image based on your description:", + }, + { + type: "image", + data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + mimeType: "image/png", + }, + ], +}; + export const Empty: Story = { args: { - content: [], + result: emptyResult, }, }; export const TextResult: Story = { args: { - content: [ - { - type: "text", - text: "The current weather in San Francisco is 65°F and sunny.", - }, - ], + result: textResult, }, }; export const JsonResult: Story = { args: { - content: [ - { - type: "json", - text: '{"temperature":65,"unit":"fahrenheit","condition":"sunny","city":"San Francisco"}', - }, - ], + result: jsonResult, }, }; export const ImageResult: Story = { args: { - content: [ - { - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", - mimeType: "image/png", - }, - ], + result: imageResult, }, }; export const MixedContent: Story = { args: { - content: [ - { - type: "text", - text: "Here is the generated image based on your description:", - }, - { - type: "image", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", - mimeType: "image/png", - }, - ], + result: mixedResult, }, }; diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx index 86956c37..13a41e91 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx @@ -1,16 +1,9 @@ import { Button, Group, Stack, Text, Title } from "@mantine/core"; -import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; -export interface ResultContentItem { - type: string; - text?: string; - data?: string; - mimeType?: string; -} - export interface ToolResultPanelProps { - content: ResultContentItem[]; + result: CallToolResult; onClear: () => void; } @@ -19,41 +12,24 @@ const ClearButton = Button.withProps({ size: "sm", }); -function toContentBlock(item: ResultContentItem): ContentBlock | null { - if (item.text !== undefined) { - return { type: "text", text: item.text }; - } - if (item.data !== undefined && item.mimeType?.startsWith("image")) { - return { type: "image", data: item.data, mimeType: item.mimeType }; - } - if (item.data !== undefined && item.mimeType) { - return { type: "audio", data: item.data, mimeType: item.mimeType }; - } - return null; -} - -export function ToolResultPanel({ content, onClear }: ToolResultPanelProps) { +export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) { return ( Results Clear - {content.length === 0 ? ( + {result.content.length === 0 ? ( No results yet ) : ( <> - {content.map((item, index) => { - const block = toContentBlock(item); - if (!block) return null; - return ( - - ); - })} + {result.content.map((block, index) => ( + + ))} )} diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx index a1725a02..4748f1fb 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx @@ -1,9 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { fn } from "storybook/test"; import { ToolsScreen } from "./ToolsScreen"; -import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem"; -import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel"; -import type { ToolResultPanelProps } from "../../groups/ToolResultPanel/ToolResultPanel"; +import type { ToolCallState } from "./ToolsScreen"; const meta: Meta = { title: "Screens/ToolsScreen", @@ -13,85 +12,65 @@ const meta: Meta = { listChanged: false, onRefreshList: fn(), onSelectTool: fn(), + onCallTool: fn(), + onCancelCall: fn(), + onClearResult: fn(), }, }; export default meta; type Story = StoryObj; -const sampleTools: ToolListItemProps[] = [ +const sampleTools: Tool[] = [ { name: "send_message", title: "Send Message", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "create_record", title: "Create Record", - selected: false, - onClick: fn(), - }, - { - name: "delete_records", - selected: false, - onClick: fn(), - }, - { name: "list_users", selected: false, onClick: fn() }, - { - name: "batch_process", - selected: false, - onClick: fn(), + description: "Creates a new record with the given parameters", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Record title" }, + count: { type: "number", description: "Number of items" }, + enabled: { + type: "boolean", + description: "Whether the record is active", + }, + }, + required: ["title"], + }, }, + { name: "delete_records", inputSchema: { type: "object" } }, + { name: "list_users", inputSchema: { type: "object" } }, + { name: "batch_process", inputSchema: { type: "object" } }, ]; -const selectedToolData: ToolDetailPanelProps = { - name: "create_record", - title: "Create Record", - description: "Creates a new record with the given parameters", - schema: { - type: "object", - properties: { - title: { type: "string", description: "Record title" }, - count: { type: "number", description: "Number of items" }, - enabled: { type: "boolean", description: "Whether the record is active" }, - }, - required: ["title"], +const resultState: ToolCallState = { + status: "ok", + result: { + content: [ + { + type: "text", + text: JSON.stringify( + { + id: 42, + title: "New Record", + count: 5, + enabled: true, + createdAt: "2026-03-17T12:00:00Z", + }, + null, + 2, + ), + }, + ], }, - formValues: {}, - isExecuting: false, - onFormChange: fn(), - onExecute: fn(), - onCancel: fn(), }; -const resultData: ToolResultPanelProps = { - content: [ - { - type: "text", - text: JSON.stringify( - { - id: 42, - title: "New Record", - count: 5, - enabled: true, - createdAt: "2026-03-17T12:00:00Z", - }, - null, - 2, - ), - }, - ], - onClear: fn(), -}; - -function toolsWithSelected(selectedName: string): ToolListItemProps[] { - return sampleTools.map((tool) => ({ - ...tool, - selected: tool.name === selectedName, - })); -} - export const NoSelection: Story = { args: { tools: sampleTools, @@ -100,16 +79,16 @@ export const NoSelection: Story = { export const ToolSelected: Story = { args: { - tools: toolsWithSelected("create_record"), - selectedTool: selectedToolData, + tools: sampleTools, + selectedToolName: "create_record", }, }; export const WithResult: Story = { args: { - tools: toolsWithSelected("create_record"), - selectedTool: selectedToolData, - result: resultData, + tools: sampleTools, + selectedToolName: "create_record", + callState: resultState, }, }; @@ -119,43 +98,34 @@ export const LongToolName: Story = { ...sampleTools, { name: "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering", - selected: true, - onClick: fn(), + description: + "Executes a complex multi-table join query across the organization's internal database with support for aggregation functions, nested filtering, and pagination of large result sets.", + annotations: { + readOnlyHint: true, + }, + inputSchema: { + type: "object", + properties: { + primary_table_name: { + type: "string", + description: "The main table to query from", + }, + join_configuration: { + type: "string", + description: "JSON configuration for table joins", + }, + aggregation_functions: { + type: "string", + description: + "Comma-separated list of aggregation functions to apply", + }, + }, + required: ["primary_table_name"], + }, }, ], - selectedTool: { - name: "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering", - annotations: { - readOnly: true, - longRunning: true, - }, - description: - "Executes a complex multi-table join query across the organization's internal database with support for aggregation functions, nested filtering, and pagination of large result sets.", - schema: { - type: "object", - properties: { - primary_table_name: { - type: "string", - description: "The main table to query from", - }, - join_configuration: { - type: "string", - description: "JSON configuration for table joins", - }, - aggregation_functions: { - type: "string", - description: - "Comma-separated list of aggregation functions to apply", - }, - }, - required: ["primary_table_name"], - }, - formValues: {}, - isExecuting: false, - onFormChange: fn(), - onExecute: fn(), - onCancel: fn(), - }, + selectedToolName: + "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering", }, }; diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index e6636cfe..fbfdad05 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -1,18 +1,34 @@ +import { useState } from "react"; import { Card, Flex, Stack, Text } from "@mantine/core"; +import type { + CallToolResult, + ProgressNotification, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; import { ToolControls } from "../../groups/ToolControls/ToolControls"; import { ToolDetailPanel } from "../../groups/ToolDetailPanel/ToolDetailPanel"; import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; -import type { ToolListItemProps } from "../../groups/ToolListItem/ToolListItem"; -import type { ToolDetailPanelProps } from "../../groups/ToolDetailPanel/ToolDetailPanel"; -import type { ToolResultPanelProps } from "../../groups/ToolResultPanel/ToolResultPanel"; + +export interface ToolCallState { + status: "idle" | "pending" | "ok" | "error"; + result?: CallToolResult; + error?: string; + progress?: Pick< + ProgressNotification["params"], + "progress" | "total" | "message" + >; +} export interface ToolsScreenProps { - tools: ToolListItemProps[]; - selectedTool?: ToolDetailPanelProps; - result?: ToolResultPanelProps; + tools: Tool[]; + selectedToolName?: string; + callState?: ToolCallState; listChanged: boolean; onRefreshList: () => void; onSelectTool: (name: string) => void; + onCallTool: (name: string, args: Record) => void; + onCancelCall?: () => void; + onClearResult?: () => void; } const ScreenLayout = Flex.withProps({ @@ -46,18 +62,28 @@ const EmptyState = Text.withProps({ export function ToolsScreen({ tools, - selectedTool, - result, + selectedToolName, + callState, listChanged, onRefreshList, onSelectTool, + onCallTool, + onCancelCall, + onClearResult, }: ToolsScreenProps) { + const [formValues, setFormValues] = useState>({}); + const selectedTool = selectedToolName + ? tools.find((t) => t.name === selectedToolName) + : undefined; + const isExecuting = callState?.status === "pending"; + return ( {selectedTool ? ( - + onCallTool(selectedTool.name, formValues)} + onCancel={() => onCancelCall?.()} + /> ) : ( Select a tool to view details )} - {result ? ( - + {callState?.result ? ( + onClearResult?.()} + /> ) : ( Results will appear here )} diff --git a/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx b/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx index d5f6dcde..fd8baa01 100644 --- a/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx +++ b/clients/web/src/components/views/ConnectedView/ConnectedView.stories.tsx @@ -173,228 +173,194 @@ export const ToolsActive: Story = { { name: "send_message", title: "Send Message", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "create_record", title: "Create Record", - selected: true, - onClick: fn(), + description: "Creates a new record with the given parameters", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Record title" }, + count: { type: "number", description: "Number of items" }, + enabled: { + type: "boolean", + description: "Whether the record is active", + }, + }, + required: ["title"], + }, }, { name: "delete_records", title: "Delete Records", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "list_users", title: "List Users", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "batch_process", title: "Batch Process", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "search_files", title: "Search Files", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "read_file", title: "Read File", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "write_file", title: "Write File", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "get_schema", title: "Get Schema", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "run_query", title: "Run Query", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "create_migration", title: "Create Migration", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "validate_config", title: "Validate Config", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "deploy_service", title: "Deploy Service", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "rollback_deploy", title: "Rollback Deploy", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "check_health", title: "Check Health", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "generate_report", title: "Generate Report", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "export_csv", title: "Export CSV", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "import_data", title: "Import Data", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "send_notification", title: "Send Notification", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "schedule_task", title: "Schedule Task", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "cancel_task", title: "Cancel Task", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "list_connections", title: "List Connections", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "refresh_cache", title: "Refresh Cache", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "purge_logs", title: "Purge Logs", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "encrypt_secret", title: "Encrypt Secret", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "rotate_keys", title: "Rotate Keys", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "sync_resources", title: "Sync Resources", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "compile_template", title: "Compile Template", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "analyze_performance", title: "Analyze Performance", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, { name: "resize_image", title: "Resize Image", - selected: false, - onClick: fn(), + inputSchema: { type: "object" }, }, ]} - selectedTool={{ - name: "create_record", - title: "Create Record", - description: "Creates a new record with the given parameters", - schema: { - type: "object", - properties: { - title: { type: "string", description: "Record title" }, - count: { type: "number", description: "Number of items" }, - enabled: { - type: "boolean", - description: "Whether the record is active", + selectedToolName="create_record" + callState={{ + status: "ok", + result: { + content: [ + { + type: "text", + text: JSON.stringify( + { + id: 42, + title: "New Record", + count: 5, + enabled: true, + createdAt: "2026-03-17T12:00:00Z", + }, + null, + 2, + ), }, - }, - required: ["title"], + ], }, - formValues: {}, - isExecuting: false, - onFormChange: fn(), - onExecute: fn(), - onCancel: fn(), - }} - result={{ - content: [ - { - type: "text", - text: JSON.stringify( - { - id: 42, - title: "New Record", - count: 5, - enabled: true, - createdAt: "2026-03-17T12:00:00Z", - }, - null, - 2, - ), - }, - ], - onClear: fn(), }} listChanged={true} onRefreshList={fn()} onSelectTool={fn()} + onCallTool={fn()} + onCancelCall={fn()} + onClearResult={fn()} /> ), }, From 16f4d9b50f3a3d5aa7b2f645511cd34e40948ab1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 18 Apr 2026 17:12:02 -0400 Subject: [PATCH 2/5] Address PR #1212 code review feedback - Fix hasAnyAnnotation checking annotations.title which is never rendered - Reset formValues when switching tools via onSelectTool handler - Extract ToolProgress type from ToolDetailPanel, reuse in ToolsScreen - Fix contradictory readOnlyHint + destructiveHint in story fixture - Replace listMaxHeight function with LIST_MAX_HEIGHT constant Co-Authored-By: Claude Opus 4.6 (1M context) --- .../groups/ToolControls/ToolControls.tsx | 7 +++---- .../ToolDetailPanel.stories.tsx | 1 - .../ToolDetailPanel/ToolDetailPanel.tsx | 13 ++++++------ .../screens/ToolsScreen/ToolsScreen.tsx | 21 +++++++++---------- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index cc533276..8779e617 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -12,9 +12,8 @@ export interface ToolControlsProps { onSelectTool: (name: string) => void; } -function listMaxHeight(): string { - return "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 160px)"; -} +const LIST_MAX_HEIGHT = + "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 160px)"; export function ToolControls({ tools, @@ -44,7 +43,7 @@ export function ToolControls({ value={searchText} onChange={(e) => setSearchText(e.currentTarget.value)} /> - + {filteredTools.map((tool) => ( ; + export interface ToolDetailPanelProps { tool: Tool; formValues: Record; isExecuting: boolean; - progress?: Pick< - ProgressNotification["params"], - "progress" | "total" | "message" - >; + progress?: ToolProgress; onFormChange: (values: Record) => void; onExecute: () => void; onCancel: () => void; @@ -44,8 +46,7 @@ function resolveTitle(name: string, title?: string): string { function hasAnyAnnotation(annotations?: ToolAnnotations): boolean { return !!( annotations && - (annotations.title || - annotations.readOnlyHint || + (annotations.readOnlyHint || annotations.destructiveHint || annotations.idempotentHint || annotations.openWorldHint) diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index fbfdad05..e4458eee 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -1,22 +1,18 @@ import { useState } from "react"; import { Card, Flex, Stack, Text } from "@mantine/core"; -import type { - CallToolResult, - ProgressNotification, - Tool, -} from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { ToolControls } from "../../groups/ToolControls/ToolControls"; -import { ToolDetailPanel } from "../../groups/ToolDetailPanel/ToolDetailPanel"; +import { + ToolDetailPanel, + type ToolProgress, +} from "../../groups/ToolDetailPanel/ToolDetailPanel"; import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; export interface ToolCallState { status: "idle" | "pending" | "ok" | "error"; result?: CallToolResult; error?: string; - progress?: Pick< - ProgressNotification["params"], - "progress" | "total" | "message" - >; + progress?: ToolProgress; } export interface ToolsScreenProps { @@ -86,7 +82,10 @@ export function ToolsScreen({ selectedName={selectedToolName} listChanged={listChanged} onRefreshList={onRefreshList} - onSelectTool={onSelectTool} + onSelectTool={(name) => { + setFormValues({}); + onSelectTool(name); + }} /> From 79af8bc1946c27fa41de0dd76431cfda00e678b4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 18 Apr 2026 17:17:24 -0400 Subject: [PATCH 3/5] Surface isError in ToolResultPanel Show a red alert banner when CallToolResult.isError is true, giving a visual distinction between successful and failed tool executions. Add ErrorResult story to demonstrate the error state. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ToolResultPanel/ToolResultPanel.stories.tsx | 14 ++++++++++++++ .../groups/ToolResultPanel/ToolResultPanel.tsx | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx index 29a942ec..059b3994 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx @@ -89,3 +89,17 @@ export const MixedContent: Story = { result: mixedResult, }, }; + +export const ErrorResult: Story = { + args: { + result: { + isError: true, + content: [ + { + type: "text", + text: "ENOENT: no such file or directory, open '/data/missing.txt'", + }, + ], + }, + }, +}; diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx index 13a41e91..8906de01 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx @@ -1,4 +1,4 @@ -import { Button, Group, Stack, Text, Title } from "@mantine/core"; +import { Alert, Button, Group, Stack, Text, Title } from "@mantine/core"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; @@ -19,6 +19,11 @@ export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) { Results Clear + {result.isError && ( + + Tool execution returned an error + + )} {result.content.length === 0 ? ( No results yet ) : ( From 38965babd321b78fa02938ce66417cc05fbb1a14 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 18 Apr 2026 17:32:14 -0400 Subject: [PATCH 4/5] Show tool error message inside the alert box Instead of a separate alert banner and content below, render error content directly inside a titled Alert with "Tool Error" heading. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../groups/ToolResultPanel/ToolResultPanel.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx index 8906de01..f7a0f520 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx @@ -19,12 +19,14 @@ export function ToolResultPanel({ result, onClear }: ToolResultPanelProps) { Results Clear - {result.isError && ( - - Tool execution returned an error + {result.isError ? ( + + {result.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n")} - )} - {result.content.length === 0 ? ( + ) : result.content.length === 0 ? ( No results yet ) : ( <> From 2d66f8cb8698e9e1da17df1af5168faab592ae5d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 18 Apr 2026 17:35:04 -0400 Subject: [PATCH 5/5] Add WithError story to ToolsScreen for long error text wrapping Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ToolsScreen/ToolsScreen.stories.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx index 4748f1fb..768961b0 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx @@ -129,6 +129,25 @@ export const LongToolName: Story = { }, }; +export const WithError: Story = { + args: { + tools: sampleTools, + selectedToolName: "create_record", + callState: { + status: "error", + result: { + isError: true, + content: [ + { + type: "text", + text: 'Error executing tool "create_record": ECONNREFUSED 127.0.0.1:5432 — could not connect to the database server. The server may not be running or may be unreachable at the configured host and port. Please verify that PostgreSQL is started, the connection string is correct, and any firewall rules allow traffic on port 5432. If this is a transient issue, retrying after a short delay may resolve it.', + }, + ], + }, + }, + }, +}; + export const WithListChanged: Story = { args: { tools: sampleTools,