From 4c1d2f2c34b889fea66b808e706165e7c587e03b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Apr 2026 09:27:22 -0400 Subject: [PATCH 1/4] Phase 4: Move screen selection state into the screens themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolsScreen, PromptsScreen, and ResourcesScreen each tracked the user's current selection via `selectedXxxName` / `onSelectXxx` props, hoisting screen-local UI state into whatever rendered them. InspectorView never threaded those callbacks anywhere, so clicks in the controls panel did not display the matching detail panel — the detail panel only rendered if a parent fed selection back in. Move selection into `useState` inside each screen and drop the `selectedXxxName` / `onSelectXxx` props. MCP-derived data (`callState`, `getPromptState`, `readState`) keeps flowing in from `InspectorView` as props. ResourcesScreen wraps `onReadResource` so a template-expanded URI becomes the selected resource for preview rendering. Update each screen's stories to drop the deleted props. Stories that need a pre-selected item use a `play` function to click it — no component coupling to Storybook. Add Phase 4.3 to the interfaces plan describing the rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PromptsScreen/PromptsScreen.stories.tsx | 24 +++-- .../screens/PromptsScreen/PromptsScreen.tsx | 9 +- .../ResourcesScreen.stories.tsx | 94 ++++++++++--------- .../ResourcesScreen/ResourcesScreen.tsx | 29 +++--- .../ToolsScreen/ToolsScreen.stories.tsx | 38 ++++++-- .../screens/ToolsScreen/ToolsScreen.tsx | 9 +- .../views/InspectorView/InspectorView.tsx | 4 - specification/v2_ux_interfaces_plan.md | 50 ++++++++++ 8 files changed, 171 insertions(+), 86 deletions(-) diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx index bffb91d5..f947a071 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; -import { fn } from "storybook/test"; +import { fn, userEvent, within } from "storybook/test"; import { PromptsScreen } from "./PromptsScreen"; import type { GetPromptState } from "./PromptsScreen"; @@ -10,7 +10,6 @@ const meta: Meta = { parameters: { layout: "fullscreen" }, args: { onRefreshList: fn(), - onSelectPrompt: fn(), onGetPrompt: fn(), onCopyMessages: fn(), listChanged: false, @@ -70,6 +69,11 @@ const translateResult: GetPromptState = { }, }; +async function selectPromptByName(canvasElement: HTMLElement, name: string) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByText(name)); +} + export const NoSelection: Story = { args: { prompts: samplePrompts, @@ -79,36 +83,44 @@ export const NoSelection: Story = { export const PromptSelected: Story = { args: { prompts: samplePrompts, - selectedPromptName: "translate", + }, + play: async ({ canvasElement }) => { + await selectPromptByName(canvasElement, "translate"); }, }; export const WithResult: Story = { args: { prompts: samplePrompts, - selectedPromptName: "translate", getPromptState: translateResult, }, + play: async ({ canvasElement }) => { + await selectPromptByName(canvasElement, "translate"); + }, }; export const Loading: Story = { args: { prompts: samplePrompts, - selectedPromptName: "translate", getPromptState: { status: "pending" }, }, + play: async ({ canvasElement }) => { + await selectPromptByName(canvasElement, "translate"); + }, }; export const WithError: Story = { args: { prompts: samplePrompts, - selectedPromptName: "translate", getPromptState: { status: "error", error: 'Prompt "translate" requires argument "text" but none was provided. Please fill in all required arguments before submitting.', }, }, + play: async ({ canvasElement }) => { + await selectPromptByName(canvasElement, "translate"); + }, }; export const ListChanged: Story = { diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index 17cc8cf7..3e1484ed 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -25,11 +25,9 @@ export interface GetPromptState { export interface PromptsScreenProps { prompts: Prompt[]; - selectedPromptName?: string; getPromptState?: GetPromptState; listChanged: boolean; onRefreshList: () => void; - onSelectPrompt: (name: string) => void; onGetPrompt: (name: string, args: Record) => void; onCopyMessages?: () => void; } @@ -65,14 +63,15 @@ const EmptyState = Text.withProps({ export function PromptsScreen({ prompts, - selectedPromptName, getPromptState, listChanged, onRefreshList, - onSelectPrompt, onGetPrompt, onCopyMessages, }: PromptsScreenProps) { + const [selectedPromptName, setSelectedPromptName] = useState< + string | undefined + >(undefined); const [argumentValues, setArgumentValues] = useState>( {}, ); @@ -91,7 +90,7 @@ export function PromptsScreen({ onRefreshList={onRefreshList} onSelectPrompt={(name) => { setArgumentValues({}); - onSelectPrompt(name); + setSelectedPromptName(name); }} /> diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index b6cb9c66..4867920d 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -4,7 +4,7 @@ import type { ResourceTemplate, } from "@modelcontextprotocol/sdk/types.js"; import type { InspectorResourceSubscription } from "../../../../../../core/mcp/types.js"; -import { fn } from "storybook/test"; +import { fn, userEvent, within } from "storybook/test"; import { ResourcesScreen } from "./ResourcesScreen"; import type { ReadResourceState } from "./ResourcesScreen"; @@ -14,8 +14,6 @@ const meta: Meta = { parameters: { layout: "fullscreen" }, args: { onRefreshList: fn(), - onSelectUri: fn(), - onSelectTemplate: fn(), onReadResource: fn(), onSubscribeResource: fn(), onUnsubscribeResource: fn(), @@ -105,6 +103,40 @@ const readConfigState: ReadResourceState = { isSubscribed: true, }; +const readUserProfileState: ReadResourceState = { + status: "ok", + uri: "file:///users/42/profile", + result: { + contents: [ + { + uri: "file:///users/42/profile", + mimeType: "application/json", + text: JSON.stringify( + { id: 42, name: "Alice", email: "alice@example.com" }, + null, + 2, + ), + }, + ], + }, + isSubscribed: false, +}; + +async function clickByText(canvasElement: HTMLElement, label: string) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByText(label)); +} + +async function expandUserProfileTemplate(canvasElement: HTMLElement) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByText("User Profile")); + const userIdInput = await canvas.findByLabelText("userId"); + await userEvent.type(userIdInput, "42"); + await userEvent.click( + await canvas.findByRole("button", { name: "Read Resource" }), + ); +} + export const WithResources: Story = { args: { resources: sampleResources, @@ -115,9 +147,11 @@ export const ResourceSelected: Story = { args: { resources: sampleResources, subscriptions: sampleSubscriptions, - selectedResourceUri: "file:///config.json", readState: readConfigState, }, + play: async ({ canvasElement }) => { + await clickByText(canvasElement, "config.json"); + }, }; export const WithTemplates: Story = { @@ -131,7 +165,9 @@ export const TemplateSelected: Story = { args: { resources: sampleResources, templates: sampleTemplates, - selectedTemplateUri: "file:///users/{userId}/profile", + }, + play: async ({ canvasElement }) => { + await clickByText(canvasElement, "User Profile"); }, }; @@ -139,26 +175,10 @@ export const TemplateWithResource: Story = { args: { resources: sampleResources, templates: sampleTemplates, - selectedTemplateUri: "file:///users/{userId}/profile", - readState: { - status: "ok", - uri: "file:///users/42/profile", - result: { - contents: [ - { - uri: "file:///users/42/profile", - mimeType: "application/json", - text: JSON.stringify( - { id: 42, name: "Alice", email: "alice@example.com" }, - null, - 2, - ), - }, - ], - }, - isSubscribed: false, - }, - selectedResourceUri: "file:///users/42/profile", + readState: readUserProfileState, + }, + play: async ({ canvasElement }) => { + await expandUserProfileTemplate(canvasElement); }, }; @@ -167,26 +187,10 @@ export const AllSections: Story = { resources: sampleResources, templates: sampleTemplates, subscriptions: sampleSubscriptions, - selectedTemplateUri: "file:///users/{userId}/profile", - readState: { - status: "ok", - uri: "file:///users/42/profile", - result: { - contents: [ - { - uri: "file:///users/42/profile", - mimeType: "application/json", - text: JSON.stringify( - { id: 42, name: "Alice", email: "alice@example.com" }, - null, - 2, - ), - }, - ], - }, - isSubscribed: false, - }, - selectedResourceUri: "file:///users/42/profile", + readState: readUserProfileState, + }, + play: async ({ canvasElement }) => { + await expandUserProfileTemplate(canvasElement); }, }; diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 85ce0816..0bbc0891 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Alert, Card, @@ -31,13 +32,9 @@ export interface ResourcesScreenProps { resources: Resource[]; templates: ResourceTemplate[]; subscriptions: InspectorResourceSubscription[]; - selectedResourceUri?: string; - selectedTemplateUri?: string; readState?: ReadResourceState; listChanged: boolean; onRefreshList: () => void; - onSelectUri: (uri: string) => void; - onSelectTemplate: (uriTemplate: string) => void; onReadResource: (uri: string) => void; onSubscribeResource: (uri: string) => void; onUnsubscribeResource: (uri: string) => void; @@ -78,17 +75,20 @@ export function ResourcesScreen({ resources, templates, subscriptions, - selectedResourceUri, - selectedTemplateUri, readState, listChanged, onRefreshList, - onSelectUri, - onSelectTemplate, onReadResource, onSubscribeResource, onUnsubscribeResource, }: ResourcesScreenProps) { + const [selectedResourceUri, setSelectedResourceUri] = useState< + string | undefined + >(undefined); + const [selectedTemplateUri, setSelectedTemplateUri] = useState< + string | undefined + >(undefined); + const selectedResource = selectedResourceUri ? resources.find((r) => r.uri === selectedResourceUri) : undefined; @@ -104,6 +104,11 @@ export function ResourcesScreen({ ? { name: readState.uri, uri: readState.uri } : undefined); + function handleReadResource(uri: string) { + setSelectedResourceUri(uri); + onReadResource(uri); + } + function renderReadState() { if (!readState) return null; @@ -136,7 +141,7 @@ export function ResourcesScreen({ contents={readState.result.contents} lastUpdated={readState.lastUpdated} isSubscribed={readState.isSubscribed ?? false} - onRefresh={() => onReadResource(readResource.uri)} + onRefresh={() => handleReadResource(readResource.uri)} onSubscribe={() => onSubscribeResource(readResource.uri)} onUnsubscribe={() => onUnsubscribeResource(readResource.uri)} /> @@ -159,8 +164,8 @@ export function ResourcesScreen({ selectedTemplateUri={selectedTemplateUri} listChanged={listChanged} onRefreshList={onRefreshList} - onSelectUri={onSelectUri} - onSelectTemplate={onSelectTemplate} + onSelectUri={setSelectedResourceUri} + onSelectTemplate={setSelectedTemplateUri} onUnsubscribeResource={onUnsubscribeResource} /> @@ -172,7 +177,7 @@ export function ResourcesScreen({ diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx index 935fc684..24dc86b7 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ElicitRequest, Tool } from "@modelcontextprotocol/sdk/types.js"; import { Modal } from "@mantine/core"; -import { fn } from "storybook/test"; +import { fn, userEvent, within } from "storybook/test"; import { ToolsScreen } from "./ToolsScreen"; import type { ToolCallState } from "./ToolsScreen"; import { SamplingRequestPanel } from "../../groups/SamplingRequestPanel/SamplingRequestPanel"; @@ -17,7 +17,6 @@ const meta: Meta = { args: { listChanged: false, onRefreshList: fn(), - onSelectTool: fn(), onCallTool: fn(), onCancelCall: fn(), onClearResult: fn(), @@ -77,6 +76,11 @@ const resultState: ToolCallState = { }, }; +async function selectToolByLabel(canvasElement: HTMLElement, label: string) { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByText(label)); +} + export const NoSelection: Story = { args: { tools: sampleTools, @@ -86,16 +90,20 @@ export const NoSelection: Story = { export const ToolSelected: Story = { args: { tools: sampleTools, - selectedToolName: "create_record", + }, + play: async ({ canvasElement }) => { + await selectToolByLabel(canvasElement, "Create Record"); }, }; export const WithResult: Story = { args: { tools: sampleTools, - selectedToolName: "create_record", callState: resultState, }, + play: async ({ canvasElement }) => { + await selectToolByLabel(canvasElement, "Create Record"); + }, }; export const LongToolName: Story = { @@ -130,15 +138,18 @@ export const LongToolName: Story = { }, }, ], - selectedToolName: + }, + play: async ({ canvasElement }) => { + await selectToolByLabel( + canvasElement, "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering", + ); }, }; export const WithError: Story = { args: { tools: sampleTools, - selectedToolName: "create_record", callState: { status: "error", result: { @@ -152,6 +163,9 @@ export const WithError: Story = { }, }, }, + play: async ({ canvasElement }) => { + await selectToolByLabel(canvasElement, "Create Record"); + }, }; export const WithListChanged: Story = { @@ -176,9 +190,11 @@ const elicitFormRequest = { export const WithSamplingModal: Story = { args: { tools: sampleTools, - selectedToolName: "create_record", callState: { status: "pending" }, }, + play: async ({ canvasElement }) => { + await selectToolByLabel(canvasElement, "Create Record"); + }, render: (args) => ( <> @@ -217,9 +233,11 @@ export const WithSamplingModal: Story = { export const WithElicitationModal: Story = { args: { tools: sampleTools, - selectedToolName: "create_record", callState: { status: "pending" }, }, + play: async ({ canvasElement }) => { + await selectToolByLabel(canvasElement, "Create Record"); + }, render: (args) => ( <> @@ -240,9 +258,11 @@ export const WithElicitationModal: Story = { export const WithPendingRequests: Story = { args: { tools: sampleTools, - selectedToolName: "create_record", callState: { status: "pending" }, }, + play: async ({ canvasElement }) => { + await selectToolByLabel(canvasElement, "Create Record"); + }, render: (args) => ( <> diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index e4458eee..875be843 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -17,11 +17,9 @@ export interface ToolCallState { export interface ToolsScreenProps { tools: Tool[]; - selectedToolName?: string; callState?: ToolCallState; listChanged: boolean; onRefreshList: () => void; - onSelectTool: (name: string) => void; onCallTool: (name: string, args: Record) => void; onCancelCall?: () => void; onClearResult?: () => void; @@ -58,15 +56,16 @@ const EmptyState = Text.withProps({ export function ToolsScreen({ tools, - selectedToolName, callState, listChanged, onRefreshList, - onSelectTool, onCallTool, onCancelCall, onClearResult, }: ToolsScreenProps) { + const [selectedToolName, setSelectedToolName] = useState( + undefined, + ); const [formValues, setFormValues] = useState>({}); const selectedTool = selectedToolName ? tools.find((t) => t.name === selectedToolName) @@ -84,7 +83,7 @@ export function ToolsScreen({ onRefreshList={onRefreshList} onSelectTool={(name) => { setFormValues({}); - onSelectTool(name); + setSelectedToolName(name); }} /> diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index a15881c1..77815688 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -289,7 +289,6 @@ export function InspectorView({ tools={tools} listChanged={false} onRefreshList={noop} - onSelectTool={noop} onCallTool={noop} /> @@ -298,7 +297,6 @@ export function InspectorView({ prompts={prompts} listChanged={false} onRefreshList={noop} - onSelectPrompt={noop} onGetPrompt={noop} /> @@ -309,8 +307,6 @@ export function InspectorView({ subscriptions={subscriptions} listChanged={false} onRefreshList={noop} - onSelectUri={noop} - onSelectTemplate={noop} onReadResource={noop} onSubscribeResource={noop} onUnsubscribeResource={noop} diff --git a/specification/v2_ux_interfaces_plan.md b/specification/v2_ux_interfaces_plan.md index 26e07a31..2fc71087 100644 --- a/specification/v2_ux_interfaces_plan.md +++ b/specification/v2_ux_interfaces_plan.md @@ -532,6 +532,56 @@ existing stories) rather than a screen refactor. - No prop changes needed. - Optional: add `connectionStatus` if the embedded header needs it. +### 4.3 Move selection state into the screens + +The Phase 2 cluster work threaded `selectedXxxName` / `onSelectXxx` props +through every list-and-detail screen, leaving selection as caller-owned +state. That choice contradicts Phase 3's "selected name stays in `useState` +inside the screen" rule and creates a real wiring gap: nothing above the +screen has any reason to know which tool/prompt/resource the user clicked +on, so under `InspectorView` clicks would have to be round-tripped through +`App.tsx` purely to get the detail panel to render. + +Selection is screen-local UI state. MCP-derived data (`callState` / fetched +prompt messages / resource read results) keeps flowing in from +`InspectorView` as props. Refactor each screen accordingly. + +Screens to fix: + +- **`ToolsScreen`** — drop `selectedToolName` and `onSelectTool` from the + prop interface; track the selected tool name in `useState` inside the + screen. `ToolControls` continues to receive `selectedName` and + `onSelectTool` as props from the screen. `callState` (and any future + result/progress payload) keeps coming in from `InspectorView`. + `InspectorView` only needs to know the selected tool name when it + triggers `onCallTool` — which the screen already owns, so it can pass + the name up at call time rather than maintaining it in the parent. +- **`PromptsScreen`** — same pattern for `selectedPromptName`. Fetched + `GetPromptResult` continues to arrive as a prop from `InspectorView`. +- **`ResourcesScreen`** — same pattern for the selected resource URI and + selected resource template URI. `ReadResourceResult` continues to + arrive as a prop. + +For each screen: + +1. Remove the `selectedXxxName` / `onSelectXxx` props from the screen's + prop interface. +2. Add a `useState` for the selection inside the screen and pass it down + to the controls group. +3. When the user triggers a request (`onCallTool` / `onGetPrompt` / + `onReadResource`), pass the currently selected name/uri up alongside + any other arguments — the parent does not need to track selection + between calls. +4. Update each screen's stories to remove the now-deleted props. The + stories will start working without `useArgs` selection wiring, because + selection is now genuinely internal. +5. Audit the controls groups (`ToolControls`, `PromptControls`, + `ResourceControls`) — their `selectedName` / `onSelectXxx` props are + correct (they ARE caller-owned, because the screen owns them) and + should not change. +6. Update `InspectorView` and any wiring fixtures to drop the now-removed + selection props they were passing to each screen. + ## Phase 5 — Cleanup pass After all four phases, sweep: From bb8241c2aa0090cb52f6d247ef422a3747b2eb60 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Apr 2026 11:25:54 -0400 Subject: [PATCH 2/4] Share long tool fixture across ToolControls and InspectorView stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a 20-tool fixture (filesystem, database, git, http, misc) into ToolsScreen.fixtures.ts — large enough that the ToolControls list scrolls and the Tools tab in InspectorView shows realistic surface area. Wire it into the Default stories of both ToolControls and InspectorView, mirroring the LoggingScreen.fixtures.ts pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ToolControls/ToolControls.stories.tsx | 28 +- .../ToolsScreen/ToolsScreen.fixtures.ts | 328 ++++++++++++++++++ .../InspectorView/InspectorView.stories.tsx | 29 +- 3 files changed, 335 insertions(+), 50 deletions(-) create mode 100644 clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx index 87dd8719..0dea7f16 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx @@ -1,7 +1,7 @@ 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"; +import { longToolList } from "../../screens/ToolsScreen/ToolsScreen.fixtures"; const meta: Meta = { title: "Groups/ToolControls", @@ -16,44 +16,28 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const sampleTools: Tool[] = [ - { - name: "send_message", - title: "Send Message", - inputSchema: { type: "object" }, - }, - { - name: "create_record", - title: "Create Record", - inputSchema: { type: "object" }, - }, - { name: "delete_records", inputSchema: { type: "object" } }, - { name: "list_users", inputSchema: { type: "object" } }, - { name: "batch_process", inputSchema: { type: "object" } }, -]; - export const Default: Story = { args: { - tools: sampleTools, + tools: longToolList, }, }; export const WithSelection: Story = { args: { - tools: sampleTools, - selectedName: "create_record", + tools: longToolList, + selectedName: "query_database", }, }; export const WithSearch: Story = { args: { - tools: sampleTools, + tools: longToolList, }, }; export const ListChanged: Story = { args: { - tools: sampleTools, + tools: longToolList, listChanged: true, }, }; diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts new file mode 100644 index 00000000..85e90106 --- /dev/null +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts @@ -0,0 +1,328 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +export const longToolList: Tool[] = [ + { + name: "read_file", + title: "Read File", + description: + "Read the contents of a text file from the local filesystem. Returns the file's contents as a UTF-8 string along with metadata.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "Absolute or relative file path" }, + encoding: { + type: "string", + enum: ["utf-8", "ascii", "latin1"], + description: "Text encoding to use", + }, + }, + required: ["path"], + }, + outputSchema: { + type: "object", + properties: { + contents: { type: "string" }, + bytes: { type: "number" }, + }, + required: ["contents", "bytes"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "write_file", + title: "Write File", + description: + "Write the given contents to a file at the specified path, creating it if it does not exist.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "Destination file path" }, + contents: { type: "string", description: "Text contents to write" }, + overwrite: { + type: "boolean", + description: "Allow overwriting an existing file", + }, + }, + required: ["path", "contents"], + }, + annotations: { destructiveHint: true }, + }, + { + name: "list_directory", + title: "List Directory", + description: "List the entries in a directory, optionally recursing.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "Directory to list" }, + recursive: { type: "boolean", description: "Recurse into subdirs" }, + }, + required: ["path"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "delete_file", + title: "Delete File", + description: + "Delete a file from the filesystem. This action cannot be undone.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "File to delete" }, + }, + required: ["path"], + }, + annotations: { destructiveHint: true, idempotentHint: true }, + }, + { + name: "search_files", + title: "Search Files", + description: + "Search for files matching a glob pattern under the given root.", + inputSchema: { + type: "object", + properties: { + root: { type: "string" }, + pattern: { type: "string", description: "e.g. **/*.ts" }, + maxResults: { type: "number" }, + }, + required: ["root", "pattern"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "query_database", + title: "Query Database", + description: + "Execute a read-only SQL query against the configured database connection.", + inputSchema: { + type: "object", + properties: { + sql: { type: "string", description: "SELECT statement" }, + params: { + type: "array", + description: "Positional bind parameters", + items: {}, + }, + limit: { type: "number" }, + }, + required: ["sql"], + }, + outputSchema: { + type: "object", + properties: { + rows: { type: "array", items: { type: "object" } }, + rowCount: { type: "number" }, + }, + required: ["rows", "rowCount"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "insert_record", + title: "Insert Record", + description: "Insert a new record into the named table.", + inputSchema: { + type: "object", + properties: { + table: { type: "string" }, + values: { type: "object", description: "Column → value map" }, + }, + required: ["table", "values"], + }, + }, + { + name: "update_record", + title: "Update Record", + description: "Update an existing record by primary key.", + inputSchema: { + type: "object", + properties: { + table: { type: "string" }, + id: { type: "string" }, + values: { type: "object" }, + }, + required: ["table", "id", "values"], + }, + annotations: { idempotentHint: true }, + }, + { + name: "delete_record", + title: "Delete Record", + description: "Delete a record from the named table by primary key.", + inputSchema: { + type: "object", + properties: { + table: { type: "string" }, + id: { type: "string" }, + }, + required: ["table", "id"], + }, + annotations: { destructiveHint: true, idempotentHint: true }, + }, + { + name: "get_schema", + title: "Get Schema", + description: "Return the schema definition for the named table or view.", + inputSchema: { + type: "object", + properties: { + table: { type: "string" }, + }, + required: ["table"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "git_status", + title: "Git Status", + description: "Show the working tree status of the given repository.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string" }, + }, + required: ["repoPath"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "git_commit", + title: "Git Commit", + description: + "Create a new commit on the current branch with the given message.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string" }, + message: { type: "string" }, + files: { type: "array", items: { type: "string" } }, + }, + required: ["repoPath", "message"], + }, + }, + { + name: "git_push", + title: "Git Push", + description: + "Push commits to the configured remote. Requires network access.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string" }, + remote: { type: "string" }, + branch: { type: "string" }, + }, + required: ["repoPath"], + }, + annotations: { openWorldHint: true }, + }, + { + name: "git_log", + title: "Git Log", + description: "List recent commits on the current branch.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string" }, + limit: { type: "number" }, + }, + required: ["repoPath"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "git_diff", + title: "Git Diff", + description: "Show changes between commits, branches, or the working tree.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string" }, + ref: { type: "string", description: "Optional revision range" }, + }, + required: ["repoPath"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "fetch_url", + title: "Fetch URL", + description: + "Issue an HTTP GET request and return the response body and headers.", + inputSchema: { + type: "object", + properties: { + url: { type: "string", description: "URL to fetch" }, + headers: { type: "object" }, + }, + required: ["url"], + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + { + name: "post_request", + title: "Post Request", + description: "Send a JSON HTTP POST to the given URL.", + inputSchema: { + type: "object", + properties: { + url: { type: "string" }, + body: {}, + headers: { type: "object" }, + }, + required: ["url", "body"], + }, + annotations: { openWorldHint: true }, + }, + { + name: "parse_html", + title: "Parse HTML", + description: + "Parse an HTML document and return a structured representation.", + inputSchema: { + type: "object", + properties: { + html: { type: "string" }, + selector: { + type: "string", + description: "Optional CSS selector to scope the parse", + }, + }, + required: ["html"], + }, + annotations: { readOnlyHint: true }, + }, + { + name: "send_email", + title: "Send Email", + description: "Send an email through the configured SMTP relay.", + inputSchema: { + type: "object", + properties: { + to: { type: "array", items: { type: "string" } }, + subject: { type: "string" }, + body: { type: "string" }, + attachments: { type: "array", items: { type: "string" } }, + }, + required: ["to", "subject", "body"], + }, + annotations: { openWorldHint: true }, + }, + { + name: "schedule_task", + title: "Schedule Task", + description: + "Schedule a background task to run at the specified time or interval.", + inputSchema: { + type: "object", + properties: { + name: { type: "string" }, + cron: { type: "string", description: "Cron expression" }, + payload: { type: "object" }, + }, + required: ["name", "cron"], + }, + }, +]; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 3c070674..eb38596b 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -3,7 +3,6 @@ import type { Resource, ResourceTemplate, Task, - Tool, } from "@modelcontextprotocol/sdk/types.js"; import type { InspectorResourceSubscription, @@ -14,6 +13,7 @@ import { fn } from "storybook/test"; import { InspectorView } from "./InspectorView"; import type { ServerEntry } from "../../screens/ServerListScreen/ServerListScreen"; import { mixedEntries as demoLogs } from "../../screens/LoggingScreen/LoggingScreen.fixtures"; +import { longToolList as demoTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; const demoServers: ServerEntry[] = [ @@ -43,33 +43,6 @@ const demoServers: ServerEntry[] = [ }, ]; -const demoTools: Tool[] = [ - { - name: "send_message", - title: "Send Message", - inputSchema: { type: "object" }, - }, - { - name: "create_record", - title: "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"], - }, - }, - { name: "delete_records", inputSchema: { type: "object" } }, - { name: "list_users", inputSchema: { type: "object" } }, -]; - const demoPrompts: Prompt[] = [ { name: "summarize", From d5559b4ea680741a40a247b43a4f621524528071 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Apr 2026 11:38:11 -0400 Subject: [PATCH 3/4] Make ResourcesScreen template/resource selection mutually exclusive Selecting a resource now clears the active template selection (and vice versa), so the right-hand panel reflects what the user just clicked. The template's Read Resource button still only sets the resource URI, preserving the template-with-preview layout for the read flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../screens/ResourcesScreen/ResourcesScreen.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index 0bbc0891..08860cff 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -104,6 +104,16 @@ export function ResourcesScreen({ ? { name: readState.uri, uri: readState.uri } : undefined); + function handleSelectResource(uri: string) { + setSelectedTemplateUri(undefined); + setSelectedResourceUri(uri); + } + + function handleSelectTemplate(uriTemplate: string) { + setSelectedResourceUri(undefined); + setSelectedTemplateUri(uriTemplate); + } + function handleReadResource(uri: string) { setSelectedResourceUri(uri); onReadResource(uri); @@ -164,8 +174,8 @@ export function ResourcesScreen({ selectedTemplateUri={selectedTemplateUri} listChanged={listChanged} onRefreshList={onRefreshList} - onSelectUri={setSelectedResourceUri} - onSelectTemplate={setSelectedTemplateUri} + onSelectUri={handleSelectResource} + onSelectTemplate={handleSelectTemplate} onUnsubscribeResource={onUnsubscribeResource} /> From 17d8532396f1abc6f6c7bf89e7772cc99e7dcde9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 26 Apr 2026 17:01:48 -0400 Subject: [PATCH 4/4] Address PR #1229 code review feedback - ToolsScreen.fixtures.ts: replace empty `{}` schemas in query_database params and post_request body with explicit `type` for consistency - ToolControls WithSearch story: add play function that types "git" so the story actually demonstrates filtering - ResourcesScreen stories: comment expandUserProfileTemplate to document the userId/readUserProfileState.uri coupling Co-Authored-By: Claude Opus 4.7 (1M context) --- .../groups/ToolControls/ToolControls.stories.tsx | 9 ++++++++- .../screens/ResourcesScreen/ResourcesScreen.stories.tsx | 3 +++ .../screens/ToolsScreen/ToolsScreen.fixtures.ts | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx index 0dea7f16..4bc75f7d 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "storybook/test"; +import { fn, userEvent, within } from "storybook/test"; import { ToolControls } from "./ToolControls"; import { longToolList } from "../../screens/ToolsScreen/ToolsScreen.fixtures"; @@ -33,6 +33,13 @@ export const WithSearch: Story = { args: { tools: longToolList, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type( + await canvas.findByPlaceholderText("Search tools..."), + "git", + ); + }, }; export const ListChanged: Story = { diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index 4867920d..b8d21e08 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -127,6 +127,9 @@ async function clickByText(canvasElement: HTMLElement, label: string) { await userEvent.click(await canvas.findByText(label)); } +// The userId value typed below must match the `{userId}` segment in +// `readUserProfileState.uri` so the synthetic-resource fallback in +// ResourcesScreen matches and the preview panel renders. async function expandUserProfileTemplate(canvasElement: HTMLElement) { const canvas = within(canvasElement); await userEvent.click(await canvas.findByText("User Profile")); diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts index 85e90106..4da11f54 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.fixtures.ts @@ -103,7 +103,7 @@ export const longToolList: Tool[] = [ params: { type: "array", description: "Positional bind parameters", - items: {}, + items: { type: "string" }, }, limit: { type: "number" }, }, @@ -269,7 +269,7 @@ export const longToolList: Tool[] = [ type: "object", properties: { url: { type: "string" }, - body: {}, + body: { type: "object", description: "JSON request body" }, headers: { type: "object" }, }, required: ["url", "body"],