Merge pull request #1229 from modelcontextprotocol/v2/phase-4-screen-selection-state

Phase 4: Move screen selection state into the screens
This commit is contained in:
Cliff Hall 2026-04-26 17:06:20 -04:00 • committed by GitHub
commit 56abced72b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 527 additions and 137 deletions

View file

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { fn, userEvent, within } from "storybook/test";
import { fn } from "storybook/test";
import { ToolControls } from "./ToolControls"; import { ToolControls } from "./ToolControls";
import { longToolList } from "../../screens/ToolsScreen/ToolsScreen.fixtures";
const meta: Meta<typeof ToolControls> = { const meta: Meta<typeof ToolControls> = {
title: "Groups/ToolControls", title: "Groups/ToolControls",
@ -16,44 +16,35 @@ const meta: Meta<typeof ToolControls> = {
export default meta; export default meta;
type Story = StoryObj<typeof ToolControls>; type Story = StoryObj<typeof ToolControls>;
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 = { export const Default: Story = {
args: { args: {
tools: sampleTools, tools: longToolList,
}, },
}; };
export const WithSelection: Story = { export const WithSelection: Story = {
args: { args: {
tools: sampleTools, tools: longToolList,
selectedName: "create_record", selectedName: "query_database",
}, },
}; };
export const WithSearch: Story = { export const WithSearch: Story = {
args: { args: {
tools: sampleTools, tools: longToolList,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByPlaceholderText("Search tools..."),
"git",
);
}, },
}; };
export const ListChanged: Story = { export const ListChanged: Story = {
args: { args: {
tools: sampleTools, tools: longToolList,
listChanged: true, listChanged: true,
}, },
}; };

View file

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; 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 { PromptsScreen } from "./PromptsScreen";
import type { GetPromptState } from "./PromptsScreen"; import type { GetPromptState } from "./PromptsScreen";
@ -10,7 +10,6 @@ const meta: Meta<typeof PromptsScreen> = {
parameters: { layout: "fullscreen" }, parameters: { layout: "fullscreen" },
args: { args: {
onRefreshList: fn(), onRefreshList: fn(),
onSelectPrompt: fn(),
onGetPrompt: fn(), onGetPrompt: fn(),
onCopyMessages: fn(), onCopyMessages: fn(),
listChanged: false, 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 = { export const NoSelection: Story = {
args: { args: {
prompts: samplePrompts, prompts: samplePrompts,
@ -79,36 +83,44 @@ export const NoSelection: Story = {
export const PromptSelected: Story = { export const PromptSelected: Story = {
args: { args: {
prompts: samplePrompts, prompts: samplePrompts,
selectedPromptName: "translate", },
play: async ({ canvasElement }) => {
await selectPromptByName(canvasElement, "translate");
}, },
}; };
export const WithResult: Story = { export const WithResult: Story = {
args: { args: {
prompts: samplePrompts, prompts: samplePrompts,
selectedPromptName: "translate",
getPromptState: translateResult, getPromptState: translateResult,
}, },
play: async ({ canvasElement }) => {
await selectPromptByName(canvasElement, "translate");
},
}; };
export const Loading: Story = { export const Loading: Story = {
args: { args: {
prompts: samplePrompts, prompts: samplePrompts,
selectedPromptName: "translate",
getPromptState: { status: "pending" }, getPromptState: { status: "pending" },
}, },
play: async ({ canvasElement }) => {
await selectPromptByName(canvasElement, "translate");
},
}; };
export const WithError: Story = { export const WithError: Story = {
args: { args: {
prompts: samplePrompts, prompts: samplePrompts,
selectedPromptName: "translate",
getPromptState: { getPromptState: {
status: "error", status: "error",
error: error:
'Prompt "translate" requires argument "text" but none was provided. Please fill in all required arguments before submitting.', '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 = { export const ListChanged: Story = {

View file

@ -25,11 +25,9 @@ export interface GetPromptState {
export interface PromptsScreenProps { export interface PromptsScreenProps {
prompts: Prompt[]; prompts: Prompt[];
selectedPromptName?: string;
getPromptState?: GetPromptState; getPromptState?: GetPromptState;
listChanged: boolean; listChanged: boolean;
onRefreshList: () => void; onRefreshList: () => void;
onSelectPrompt: (name: string) => void;
onGetPrompt: (name: string, args: Record<string, string>) => void; onGetPrompt: (name: string, args: Record<string, string>) => void;
onCopyMessages?: () => void; onCopyMessages?: () => void;
} }
@ -65,14 +63,15 @@ const EmptyState = Text.withProps({
export function PromptsScreen({ export function PromptsScreen({
prompts, prompts,
selectedPromptName,
getPromptState, getPromptState,
listChanged, listChanged,
onRefreshList, onRefreshList,
onSelectPrompt,
onGetPrompt, onGetPrompt,
onCopyMessages, onCopyMessages,
}: PromptsScreenProps) { }: PromptsScreenProps) {
const [selectedPromptName, setSelectedPromptName] = useState<
string | undefined
>(undefined);
const [argumentValues, setArgumentValues] = useState<Record<string, string>>( const [argumentValues, setArgumentValues] = useState<Record<string, string>>(
{}, {},
); );
@ -91,7 +90,7 @@ export function PromptsScreen({
onRefreshList={onRefreshList} onRefreshList={onRefreshList}
onSelectPrompt={(name) => { onSelectPrompt={(name) => {
setArgumentValues({}); setArgumentValues({});
onSelectPrompt(name); setSelectedPromptName(name);
}} }}
/> />
</SidebarCard> </SidebarCard>

View file

@ -4,7 +4,7 @@ import type {
ResourceTemplate, ResourceTemplate,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import type { InspectorResourceSubscription } from "../../../../../../core/mcp/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 { ResourcesScreen } from "./ResourcesScreen";
import type { ReadResourceState } from "./ResourcesScreen"; import type { ReadResourceState } from "./ResourcesScreen";
@ -14,8 +14,6 @@ const meta: Meta<typeof ResourcesScreen> = {
parameters: { layout: "fullscreen" }, parameters: { layout: "fullscreen" },
args: { args: {
onRefreshList: fn(), onRefreshList: fn(),
onSelectUri: fn(),
onSelectTemplate: fn(),
onReadResource: fn(), onReadResource: fn(),
onSubscribeResource: fn(), onSubscribeResource: fn(),
onUnsubscribeResource: fn(), onUnsubscribeResource: fn(),
@ -105,6 +103,43 @@ const readConfigState: ReadResourceState = {
isSubscribed: true, 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));
}
// 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"));
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 = { export const WithResources: Story = {
args: { args: {
resources: sampleResources, resources: sampleResources,
@ -115,9 +150,11 @@ export const ResourceSelected: Story = {
args: { args: {
resources: sampleResources, resources: sampleResources,
subscriptions: sampleSubscriptions, subscriptions: sampleSubscriptions,
selectedResourceUri: "file:///config.json",
readState: readConfigState, readState: readConfigState,
}, },
play: async ({ canvasElement }) => {
await clickByText(canvasElement, "config.json");
},
}; };
export const WithTemplates: Story = { export const WithTemplates: Story = {
@ -131,7 +168,9 @@ export const TemplateSelected: Story = {
args: { args: {
resources: sampleResources, resources: sampleResources,
templates: sampleTemplates, templates: sampleTemplates,
selectedTemplateUri: "file:///users/{userId}/profile", },
play: async ({ canvasElement }) => {
await clickByText(canvasElement, "User Profile");
}, },
}; };
@ -139,26 +178,10 @@ export const TemplateWithResource: Story = {
args: { args: {
resources: sampleResources, resources: sampleResources,
templates: sampleTemplates, templates: sampleTemplates,
selectedTemplateUri: "file:///users/{userId}/profile", readState: readUserProfileState,
readState: { },
status: "ok", play: async ({ canvasElement }) => {
uri: "file:///users/42/profile", await expandUserProfileTemplate(canvasElement);
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",
}, },
}; };
@ -167,26 +190,10 @@ export const AllSections: Story = {
resources: sampleResources, resources: sampleResources,
templates: sampleTemplates, templates: sampleTemplates,
subscriptions: sampleSubscriptions, subscriptions: sampleSubscriptions,
selectedTemplateUri: "file:///users/{userId}/profile", readState: readUserProfileState,
readState: { },
status: "ok", play: async ({ canvasElement }) => {
uri: "file:///users/42/profile", await expandUserProfileTemplate(canvasElement);
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",
}, },
}; };

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { import {
Alert, Alert,
Card, Card,
@ -31,13 +32,9 @@ export interface ResourcesScreenProps {
resources: Resource[]; resources: Resource[];
templates: ResourceTemplate[]; templates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[]; subscriptions: InspectorResourceSubscription[];
selectedResourceUri?: string;
selectedTemplateUri?: string;
readState?: ReadResourceState; readState?: ReadResourceState;
listChanged: boolean; listChanged: boolean;
onRefreshList: () => void; onRefreshList: () => void;
onSelectUri: (uri: string) => void;
onSelectTemplate: (uriTemplate: string) => void;
onReadResource: (uri: string) => void; onReadResource: (uri: string) => void;
onSubscribeResource: (uri: string) => void; onSubscribeResource: (uri: string) => void;
onUnsubscribeResource: (uri: string) => void; onUnsubscribeResource: (uri: string) => void;
@ -78,17 +75,20 @@ export function ResourcesScreen({
resources, resources,
templates, templates,
subscriptions, subscriptions,
selectedResourceUri,
selectedTemplateUri,
readState, readState,
listChanged, listChanged,
onRefreshList, onRefreshList,
onSelectUri,
onSelectTemplate,
onReadResource, onReadResource,
onSubscribeResource, onSubscribeResource,
onUnsubscribeResource, onUnsubscribeResource,
}: ResourcesScreenProps) { }: ResourcesScreenProps) {
const [selectedResourceUri, setSelectedResourceUri] = useState<
string | undefined
>(undefined);
const [selectedTemplateUri, setSelectedTemplateUri] = useState<
string | undefined
>(undefined);
const selectedResource = selectedResourceUri const selectedResource = selectedResourceUri
? resources.find((r) => r.uri === selectedResourceUri) ? resources.find((r) => r.uri === selectedResourceUri)
: undefined; : undefined;
@ -104,6 +104,21 @@ export function ResourcesScreen({
? { name: readState.uri, uri: readState.uri } ? { name: readState.uri, uri: readState.uri }
: undefined); : 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);
}
function renderReadState() { function renderReadState() {
if (!readState) return null; if (!readState) return null;
@ -136,7 +151,7 @@ export function ResourcesScreen({
contents={readState.result.contents} contents={readState.result.contents}
lastUpdated={readState.lastUpdated} lastUpdated={readState.lastUpdated}
isSubscribed={readState.isSubscribed ?? false} isSubscribed={readState.isSubscribed ?? false}
onRefresh={() => onReadResource(readResource.uri)} onRefresh={() => handleReadResource(readResource.uri)}
onSubscribe={() => onSubscribeResource(readResource.uri)} onSubscribe={() => onSubscribeResource(readResource.uri)}
onUnsubscribe={() => onUnsubscribeResource(readResource.uri)} onUnsubscribe={() => onUnsubscribeResource(readResource.uri)}
/> />
@ -159,8 +174,8 @@ export function ResourcesScreen({
selectedTemplateUri={selectedTemplateUri} selectedTemplateUri={selectedTemplateUri}
listChanged={listChanged} listChanged={listChanged}
onRefreshList={onRefreshList} onRefreshList={onRefreshList}
onSelectUri={onSelectUri} onSelectUri={handleSelectResource}
onSelectTemplate={onSelectTemplate} onSelectTemplate={handleSelectTemplate}
onUnsubscribeResource={onUnsubscribeResource} onUnsubscribeResource={onUnsubscribeResource}
/> />
</SidebarCard> </SidebarCard>
@ -172,7 +187,7 @@ export function ResourcesScreen({
<DetailCard> <DetailCard>
<ResourceTemplatePanel <ResourceTemplatePanel
template={selectedTemplate} template={selectedTemplate}
onReadResource={onReadResource} onReadResource={handleReadResource}
/> />
</DetailCard> </DetailCard>
</ScrollArea.Autosize> </ScrollArea.Autosize>

View file

@ -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: { type: "string" },
},
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: { type: "object", description: "JSON request 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"],
},
},
];

View file

@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ElicitRequest, Tool } from "@modelcontextprotocol/sdk/types.js"; import type { ElicitRequest, Tool } from "@modelcontextprotocol/sdk/types.js";
import { Modal } from "@mantine/core"; import { Modal } from "@mantine/core";
import { fn } from "storybook/test"; import { fn, userEvent, within } from "storybook/test";
import { ToolsScreen } from "./ToolsScreen"; import { ToolsScreen } from "./ToolsScreen";
import type { ToolCallState } from "./ToolsScreen"; import type { ToolCallState } from "./ToolsScreen";
import { SamplingRequestPanel } from "../../groups/SamplingRequestPanel/SamplingRequestPanel"; import { SamplingRequestPanel } from "../../groups/SamplingRequestPanel/SamplingRequestPanel";
@ -17,7 +17,6 @@ const meta: Meta<typeof ToolsScreen> = {
args: { args: {
listChanged: false, listChanged: false,
onRefreshList: fn(), onRefreshList: fn(),
onSelectTool: fn(),
onCallTool: fn(), onCallTool: fn(),
onCancelCall: fn(), onCancelCall: fn(),
onClearResult: 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 = { export const NoSelection: Story = {
args: { args: {
tools: sampleTools, tools: sampleTools,
@ -86,16 +90,20 @@ export const NoSelection: Story = {
export const ToolSelected: Story = { export const ToolSelected: Story = {
args: { args: {
tools: sampleTools, tools: sampleTools,
selectedToolName: "create_record", },
play: async ({ canvasElement }) => {
await selectToolByLabel(canvasElement, "Create Record");
}, },
}; };
export const WithResult: Story = { export const WithResult: Story = {
args: { args: {
tools: sampleTools, tools: sampleTools,
selectedToolName: "create_record",
callState: resultState, callState: resultState,
}, },
play: async ({ canvasElement }) => {
await selectToolByLabel(canvasElement, "Create Record");
},
}; };
export const LongToolName: Story = { 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", "organization_internal_database_complex_multi_table_join_query_with_aggregation_and_filtering",
);
}, },
}; };
export const WithError: Story = { export const WithError: Story = {
args: { args: {
tools: sampleTools, tools: sampleTools,
selectedToolName: "create_record",
callState: { callState: {
status: "error", status: "error",
result: { result: {
@ -152,6 +163,9 @@ export const WithError: Story = {
}, },
}, },
}, },
play: async ({ canvasElement }) => {
await selectToolByLabel(canvasElement, "Create Record");
},
}; };
export const WithListChanged: Story = { export const WithListChanged: Story = {
@ -176,9 +190,11 @@ const elicitFormRequest = {
export const WithSamplingModal: Story = { export const WithSamplingModal: Story = {
args: { args: {
tools: sampleTools, tools: sampleTools,
selectedToolName: "create_record",
callState: { status: "pending" }, callState: { status: "pending" },
}, },
play: async ({ canvasElement }) => {
await selectToolByLabel(canvasElement, "Create Record");
},
render: (args) => ( render: (args) => (
<> <>
<ToolsScreen {...args} /> <ToolsScreen {...args} />
@ -217,9 +233,11 @@ export const WithSamplingModal: Story = {
export const WithElicitationModal: Story = { export const WithElicitationModal: Story = {
args: { args: {
tools: sampleTools, tools: sampleTools,
selectedToolName: "create_record",
callState: { status: "pending" }, callState: { status: "pending" },
}, },
play: async ({ canvasElement }) => {
await selectToolByLabel(canvasElement, "Create Record");
},
render: (args) => ( render: (args) => (
<> <>
<ToolsScreen {...args} /> <ToolsScreen {...args} />
@ -240,9 +258,11 @@ export const WithElicitationModal: Story = {
export const WithPendingRequests: Story = { export const WithPendingRequests: Story = {
args: { args: {
tools: sampleTools, tools: sampleTools,
selectedToolName: "create_record",
callState: { status: "pending" }, callState: { status: "pending" },
}, },
play: async ({ canvasElement }) => {
await selectToolByLabel(canvasElement, "Create Record");
},
render: (args) => ( render: (args) => (
<> <>
<ToolsScreen {...args} /> <ToolsScreen {...args} />

View file

@ -17,11 +17,9 @@ export interface ToolCallState {
export interface ToolsScreenProps { export interface ToolsScreenProps {
tools: Tool[]; tools: Tool[];
selectedToolName?: string;
callState?: ToolCallState; callState?: ToolCallState;
listChanged: boolean; listChanged: boolean;
onRefreshList: () => void; onRefreshList: () => void;
onSelectTool: (name: string) => void;
onCallTool: (name: string, args: Record<string, unknown>) => void; onCallTool: (name: string, args: Record<string, unknown>) => void;
onCancelCall?: () => void; onCancelCall?: () => void;
onClearResult?: () => void; onClearResult?: () => void;
@ -58,15 +56,16 @@ const EmptyState = Text.withProps({
export function ToolsScreen({ export function ToolsScreen({
tools, tools,
selectedToolName,
callState, callState,
listChanged, listChanged,
onRefreshList, onRefreshList,
onSelectTool,
onCallTool, onCallTool,
onCancelCall, onCancelCall,
onClearResult, onClearResult,
}: ToolsScreenProps) { }: ToolsScreenProps) {
const [selectedToolName, setSelectedToolName] = useState<string | undefined>(
undefined,
);
const [formValues, setFormValues] = useState<Record<string, unknown>>({}); const [formValues, setFormValues] = useState<Record<string, unknown>>({});
const selectedTool = selectedToolName const selectedTool = selectedToolName
? tools.find((t) => t.name === selectedToolName) ? tools.find((t) => t.name === selectedToolName)
@ -84,7 +83,7 @@ export function ToolsScreen({
onRefreshList={onRefreshList} onRefreshList={onRefreshList}
onSelectTool={(name) => { onSelectTool={(name) => {
setFormValues({}); setFormValues({});
onSelectTool(name); setSelectedToolName(name);
}} }}
/> />
</SidebarCard> </SidebarCard>

View file

@ -3,7 +3,6 @@ import type {
Resource, Resource,
ResourceTemplate, ResourceTemplate,
Task, Task,
Tool,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import type { import type {
InspectorResourceSubscription, InspectorResourceSubscription,
@ -14,6 +13,7 @@ import { fn } from "storybook/test";
import { InspectorView } from "./InspectorView"; import { InspectorView } from "./InspectorView";
import type { ServerEntry } from "../../screens/ServerListScreen/ServerListScreen"; import type { ServerEntry } from "../../screens/ServerListScreen/ServerListScreen";
import { mixedEntries as demoLogs } from "../../screens/LoggingScreen/LoggingScreen.fixtures"; 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"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
const demoServers: ServerEntry[] = [ 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[] = [ const demoPrompts: Prompt[] = [
{ {
name: "summarize", name: "summarize",

View file

@ -289,7 +289,6 @@ export function InspectorView({
tools={tools} tools={tools}
listChanged={false} listChanged={false}
onRefreshList={noop} onRefreshList={noop}
onSelectTool={noop}
onCallTool={noop} onCallTool={noop}
/> />
</ScreenStage> </ScreenStage>
@ -298,7 +297,6 @@ export function InspectorView({
prompts={prompts} prompts={prompts}
listChanged={false} listChanged={false}
onRefreshList={noop} onRefreshList={noop}
onSelectPrompt={noop}
onGetPrompt={noop} onGetPrompt={noop}
/> />
</ScreenStage> </ScreenStage>
@ -309,8 +307,6 @@ export function InspectorView({
subscriptions={subscriptions} subscriptions={subscriptions}
listChanged={false} listChanged={false}
onRefreshList={noop} onRefreshList={noop}
onSelectUri={noop}
onSelectTemplate={noop}
onReadResource={noop} onReadResource={noop}
onSubscribeResource={noop} onSubscribeResource={noop}
onUnsubscribeResource={noop} onUnsubscribeResource={noop}

View file

@ -532,6 +532,56 @@ existing stories) rather than a screen refactor.
- No prop changes needed. - No prop changes needed.
- Optional: add `connectionStatus` if the embedded header needs it. - 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 ## Phase 5 — Cleanup pass
After all four phases, sweep: After all four phases, sweep: