* feat(apps): AppsScreen and InspectorView wiring (#1263) Adds the Apps tab as a top-level screen composed of `AppListItem`, `AppDetailPanel`, and `AppRenderer`. The screen is presentational — selection, form values, running, and maximize state are local; data and side effects come in via props and callbacks. InspectorView filters `tools` through `isAppTool` (wrapped in a try/catch so a malformed `_meta.ui.resourceUri` skips one tool instead of dropping every following App), exposes the Apps tab in `ALL_TABS` after Tools, and wires Phase-3 stub callbacks plus a stub bridge factory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(apps): extract AppControls and rename Refresh button Pulls the sidebar list, search input, "List updated" indicator, and Refresh button out of `AppsScreen` into a dedicated `AppControls` group, mirroring `ToolControls` and `TaskControls`. The Refresh button moves from a full-width row beneath the list to the top-right of the title row (single label "Refresh", matching `TaskControls`). `AppsScreen` keeps the right-pane orchestration (selection, form, running, maximize, header actions) and now delegates the entire sidebar to `AppControls`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(stories): seed InspectorView demo tools with MCP Apps Adds three apps (Cohort Data, Weather Widget, Ops Dashboard) carrying real `_meta.ui.resourceUri` to the demo tools list so the Apps tab has content in the Default story alongside the existing tools. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(apps): drop AppDetailPanel title row to avoid duplicate name The parent AppsScreen header already shows the app name, so the panel's own title/icon row rendered the name twice. Remove the title row (and its now-unused imports, styled subcomponents, and the four tests that asserted on title/icon rendering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(resources): drop annotation badges from ResourceListItem The list row now renders just the resource title/name. Audience and priority badges remain visible in the preview/template panels, so the data is still surfaced — just not duplicated in the picker. Drops the now-unused AnnotationBadge import, the badge-only stories, and the badge-rendering test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(apps): address review — share hasInputFields, show header icon - Extracts the duplicated `Object.keys(inputSchema.properties ?? {}).length > 0` check from `AppsScreen` (and the equivalent in `AppDetailPanel`'s `hasFields`) into a shared `hasInputFields` helper in `toolUtils`, with tests. - Renders the selected tool's first icon next to the right-pane header title in `AppsScreen`, restoring the icon visibility that was lost when `AppDetailPanel`'s title row was removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(apps): finish hasInputFields extraction; document isOpening - AppDetailPanel now actually calls the shared `hasInputFields` helper instead of inlining the same `Object.keys(...).length > 0` check. - AppsScreen comments why `isOpening={false}` is hardcoded: the screen flips to AppRenderer synchronously, so the loading state is unreachable here — the prop remains for standalone use and Phase 3 wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
323f42d11c
commit
5f296589ea
17 changed files with 1210 additions and 164 deletions
|
|
@ -0,0 +1,88 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { fn, userEvent, within } from "storybook/test";
|
||||||
|
import { AppControls } from "./AppControls";
|
||||||
|
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||||
|
|
||||||
|
const sampleApps: Tool[] = [
|
||||||
|
{
|
||||||
|
name: "get-cohort-data",
|
||||||
|
title: "Cohort Data",
|
||||||
|
description: "Returns cohort retention heatmap data.",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/cohort" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weather-widget",
|
||||||
|
title: "Weather Widget",
|
||||||
|
description: "Live weather and a five-day forecast for any city.",
|
||||||
|
icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }],
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ops-dashboard",
|
||||||
|
title: "Ops Dashboard",
|
||||||
|
description: "Current operational status across services.",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git_log",
|
||||||
|
description: "Recent commits on the current branch.",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/git-log" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: Meta<typeof AppControls> = {
|
||||||
|
title: "Groups/AppControls",
|
||||||
|
component: AppControls,
|
||||||
|
args: {
|
||||||
|
onRefreshList: fn(),
|
||||||
|
onSelectApp: fn(),
|
||||||
|
listChanged: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof AppControls>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
tools: sampleApps,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSelection: Story = {
|
||||||
|
args: {
|
||||||
|
tools: sampleApps,
|
||||||
|
selectedName: "weather-widget",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSearch: Story = {
|
||||||
|
args: {
|
||||||
|
tools: sampleApps,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.type(
|
||||||
|
await canvas.findByPlaceholderText("Search apps..."),
|
||||||
|
"git",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ListChanged: Story = {
|
||||||
|
args: {
|
||||||
|
tools: sampleApps,
|
||||||
|
listChanged: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: {
|
||||||
|
tools: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { AppControls } from "./AppControls";
|
||||||
|
|
||||||
|
const sampleApps: Tool[] = [
|
||||||
|
{
|
||||||
|
name: "weather",
|
||||||
|
title: "Weather Widget",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ops",
|
||||||
|
title: "Ops Dashboard",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git_status",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/git-status" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
tools: sampleApps,
|
||||||
|
listChanged: false,
|
||||||
|
onRefreshList: vi.fn(),
|
||||||
|
onSelectApp: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AppControls", () => {
|
||||||
|
it("renders the title with the app count and a search input", () => {
|
||||||
|
renderWithMantine(<AppControls {...baseProps} />);
|
||||||
|
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("Search apps...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all apps by default", () => {
|
||||||
|
renderWithMantine(<AppControls {...baseProps} />);
|
||||||
|
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Ops Dashboard")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("git_status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters apps by name when typing in the search input", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppControls {...baseProps} />);
|
||||||
|
await user.type(screen.getByPlaceholderText("Search apps..."), "git");
|
||||||
|
expect(screen.getByText("git_status")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Weather Widget")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters apps by title when typing in the search input", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppControls {...baseProps} />);
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText("Search apps..."),
|
||||||
|
"weather widget",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'No apps available' when the tool list is empty", () => {
|
||||||
|
renderWithMantine(<AppControls {...baseProps} tools={[]} />);
|
||||||
|
expect(screen.getByText("No apps available")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'No matching apps' when search yields no results", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppControls {...baseProps} />);
|
||||||
|
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
|
||||||
|
expect(screen.getByText("No matching apps")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onSelectApp when an unselected app is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelectApp = vi.fn();
|
||||||
|
renderWithMantine(<AppControls {...baseProps} onSelectApp={onSelectApp} />);
|
||||||
|
await user.click(screen.getByText("git_status"));
|
||||||
|
expect(onSelectApp).toHaveBeenCalledWith("git_status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke onSelectApp when the already-selected app is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelectApp = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<AppControls
|
||||||
|
{...baseProps}
|
||||||
|
selectedName="git_status"
|
||||||
|
onSelectApp={onSelectApp}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText("git_status"));
|
||||||
|
expect(onSelectApp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRefreshList = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<AppControls {...baseProps} onRefreshList={onRefreshList} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
|
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the list-changed indicator when listChanged is false", () => {
|
||||||
|
renderWithMantine(<AppControls {...baseProps} />);
|
||||||
|
expect(screen.queryByText("List updated")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the list-changed indicator when listChanged is true", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRefreshList = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<AppControls {...baseProps} listChanged onRefreshList={onRefreshList} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("List updated")).toBeInTheDocument();
|
||||||
|
// Both the toolbar button and the list-changed indicator's button render
|
||||||
|
// as "Refresh"; either one should drive onRefreshList.
|
||||||
|
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
||||||
|
expect(refreshButtons).toHaveLength(2);
|
||||||
|
await user.click(refreshButtons[1]);
|
||||||
|
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
|
||||||
|
import { AppListItem } from "../AppListItem/AppListItem";
|
||||||
|
|
||||||
|
export interface AppControlsProps {
|
||||||
|
tools: Tool[];
|
||||||
|
selectedName?: string;
|
||||||
|
listChanged: boolean;
|
||||||
|
onRefreshList: () => void;
|
||||||
|
onSelectApp: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIST_MAX_HEIGHT =
|
||||||
|
"calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2 - 220px)";
|
||||||
|
|
||||||
|
const ToolbarButton = Button.withProps({
|
||||||
|
variant: "subtle",
|
||||||
|
size: "sm",
|
||||||
|
});
|
||||||
|
|
||||||
|
const EmptyState = Text.withProps({
|
||||||
|
c: "dimmed",
|
||||||
|
ta: "center",
|
||||||
|
py: "xl",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AppControls({
|
||||||
|
tools,
|
||||||
|
selectedName,
|
||||||
|
listChanged,
|
||||||
|
onRefreshList,
|
||||||
|
onSelectApp,
|
||||||
|
}: AppControlsProps) {
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const query = searchText.toLowerCase();
|
||||||
|
const filteredTools = searchText
|
||||||
|
? tools.filter(
|
||||||
|
(tool) =>
|
||||||
|
tool.name.toLowerCase().includes(query) ||
|
||||||
|
(tool.title?.toLowerCase().includes(query) ?? false),
|
||||||
|
)
|
||||||
|
: tools;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Title order={4}>MCP Apps ({tools.length})</Title>
|
||||||
|
<ToolbarButton onClick={onRefreshList}>Refresh</ToolbarButton>
|
||||||
|
</Group>
|
||||||
|
<ListChangedIndicator visible={listChanged} onRefresh={onRefreshList} />
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search apps..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<ScrollArea.Autosize mah={LIST_MAX_HEIGHT}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{filteredTools.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
{tools.length === 0 ? "No apps available" : "No matching apps"}
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
filteredTools.map((tool) => (
|
||||||
|
<AppListItem
|
||||||
|
key={tool.name}
|
||||||
|
tool={tool}
|
||||||
|
selected={tool.name === selectedName}
|
||||||
|
onClick={() => {
|
||||||
|
if (tool.name !== selectedName) onSelectApp(tool.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,6 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
import { AppDetailPanel } from "./AppDetailPanel";
|
import { AppDetailPanel } from "./AppDetailPanel";
|
||||||
|
|
||||||
const ICON_SRC = "data:image/svg+xml,%3Csvg/%3E";
|
|
||||||
|
|
||||||
const noFieldsTool: Tool = {
|
const noFieldsTool: Tool = {
|
||||||
name: "no_input_app",
|
name: "no_input_app",
|
||||||
title: "No Input App",
|
title: "No Input App",
|
||||||
|
|
@ -25,16 +23,6 @@ const requiredFieldTool: Tool = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionalFieldTool: Tool = {
|
|
||||||
name: "greet",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { type: "string", description: "The name to greet" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
formValues: {},
|
formValues: {},
|
||||||
isOpening: false,
|
isOpening: false,
|
||||||
|
|
@ -43,20 +31,6 @@ const baseProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("AppDetailPanel", () => {
|
describe("AppDetailPanel", () => {
|
||||||
it("prefers the title over the name", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Greet")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the name when title is missing", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<AppDetailPanel {...baseProps} tool={optionalFieldTool} />,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("greet")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the description when provided", () => {
|
it("renders the description when provided", () => {
|
||||||
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
|
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
|
||||||
expect(screen.getByText("Takes no parameters")).toBeInTheDocument();
|
expect(screen.getByText("Takes no parameters")).toBeInTheDocument();
|
||||||
|
|
@ -69,22 +43,6 @@ describe("AppDetailPanel", () => {
|
||||||
expect(screen.queryByText("Takes no parameters")).not.toBeInTheDocument();
|
expect(screen.queryByText("Takes no parameters")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the first icon when tool.icons is present", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<AppDetailPanel
|
|
||||||
{...baseProps}
|
|
||||||
tool={{ ...noFieldsTool, icons: [{ src: ICON_SRC }] }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const img = screen.getByRole("presentation");
|
|
||||||
expect(img).toHaveAttribute("src", ICON_SRC);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render an icon when tool.icons is missing", () => {
|
|
||||||
renderWithMantine(<AppDetailPanel {...baseProps} tool={noFieldsTool} />);
|
|
||||||
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the schema form using the tool's inputSchema", () => {
|
it("renders the schema form using the tool's inputSchema", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
<AppDetailPanel {...baseProps} tool={requiredFieldTool} />,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Button, Divider, Group, Image, Stack, Text } from "@mantine/core";
|
import { Button, Divider, Stack, Text } from "@mantine/core";
|
||||||
import { MdPlayArrow } from "react-icons/md";
|
import { MdPlayArrow } from "react-icons/md";
|
||||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { resolveDisplayLabel } from "../../../utils/toolUtils";
|
|
||||||
import { SchemaForm } from "../SchemaForm/SchemaForm";
|
import { SchemaForm } from "../SchemaForm/SchemaForm";
|
||||||
|
import { hasInputFields } from "../../../utils/toolUtils";
|
||||||
|
|
||||||
export interface AppDetailPanelProps {
|
export interface AppDetailPanelProps {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
|
|
@ -12,29 +12,11 @@ export interface AppDetailPanelProps {
|
||||||
onOpenApp: () => void;
|
onOpenApp: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PanelTitle = Text.withProps({
|
|
||||||
fw: 700,
|
|
||||||
size: "lg",
|
|
||||||
truncate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const DescriptionText = Text.withProps({
|
const DescriptionText = Text.withProps({
|
||||||
size: "sm",
|
size: "sm",
|
||||||
c: "dimmed",
|
c: "dimmed",
|
||||||
});
|
});
|
||||||
|
|
||||||
const TitleRow = Group.withProps({
|
|
||||||
gap: "sm",
|
|
||||||
align: "center",
|
|
||||||
wrap: "nowrap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const PanelIcon = Image.withProps({
|
|
||||||
w: 24,
|
|
||||||
h: 24,
|
|
||||||
fit: "contain",
|
|
||||||
});
|
|
||||||
|
|
||||||
function hasMissingRequiredFields(
|
function hasMissingRequiredFields(
|
||||||
schema: Tool["inputSchema"],
|
schema: Tool["inputSchema"],
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
|
|
@ -53,19 +35,13 @@ export function AppDetailPanel({
|
||||||
onFormChange,
|
onFormChange,
|
||||||
onOpenApp,
|
onOpenApp,
|
||||||
}: AppDetailPanelProps) {
|
}: AppDetailPanelProps) {
|
||||||
const { name, title, description, icons, inputSchema } = tool;
|
const { description, inputSchema } = tool;
|
||||||
const iconSrc = icons?.[0]?.src;
|
|
||||||
const hasErrors = hasMissingRequiredFields(inputSchema, formValues);
|
const hasErrors = hasMissingRequiredFields(inputSchema, formValues);
|
||||||
const disabled = isOpening || hasErrors;
|
const disabled = isOpening || hasErrors;
|
||||||
const hasFields = Object.keys(inputSchema.properties ?? {}).length > 0;
|
const hasFields = hasInputFields(tool);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" miw={0}>
|
<Stack gap="md" miw={0}>
|
||||||
<TitleRow>
|
|
||||||
{iconSrc && <PanelIcon src={iconSrc} alt="" />}
|
|
||||||
<PanelTitle>{resolveDisplayLabel(name, title)}</PanelTitle>
|
|
||||||
</TitleRow>
|
|
||||||
|
|
||||||
{description && <DescriptionText>{description}</DescriptionText>}
|
{description && <DescriptionText>{description}</DescriptionText>}
|
||||||
|
|
||||||
{hasFields && <Divider />}
|
{hasFields && <Divider />}
|
||||||
|
|
|
||||||
|
|
@ -53,48 +53,3 @@ export const Template: Story = {
|
||||||
selected: false,
|
selected: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithAudience: Story = {
|
|
||||||
args: {
|
|
||||||
resource: {
|
|
||||||
name: "settings.json",
|
|
||||||
uri: "file:///settings.json",
|
|
||||||
annotations: { audience: ["user"] },
|
|
||||||
},
|
|
||||||
selected: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithPriority: Story = {
|
|
||||||
args: {
|
|
||||||
resource: {
|
|
||||||
name: "schema.sql",
|
|
||||||
uri: "file:///schema.sql",
|
|
||||||
annotations: { priority: 0.8 },
|
|
||||||
},
|
|
||||||
selected: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithAudienceAndPriority: Story = {
|
|
||||||
args: {
|
|
||||||
resource: {
|
|
||||||
name: "config.json",
|
|
||||||
title: "Configuration File",
|
|
||||||
uri: "file:///config.json",
|
|
||||||
annotations: { audience: ["user", "assistant"], priority: 0.5 },
|
|
||||||
},
|
|
||||||
selected: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TemplateWithAnnotations: Story = {
|
|
||||||
args: {
|
|
||||||
resource: {
|
|
||||||
name: "User Profile",
|
|
||||||
uriTemplate: "file:///users/{userId}/profile",
|
|
||||||
annotations: { audience: ["assistant"], priority: 0.2 },
|
|
||||||
},
|
|
||||||
selected: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ describe("ResourceListItem", () => {
|
||||||
expect(screen.getByText("Configuration")).toBeInTheDocument();
|
expect(screen.getByText("Configuration")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders annotation badges when annotations are present", () => {
|
it("does not render annotation badges even when annotations are present", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ResourceListItem
|
<ResourceListItem
|
||||||
resource={{
|
resource={{
|
||||||
|
|
@ -40,19 +40,8 @@ describe("ResourceListItem", () => {
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("audience: user")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("priority: high")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides badges when only an empty audience annotation exists", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<ResourceListItem
|
|
||||||
resource={{ ...baseResource, annotations: { audience: [] } }}
|
|
||||||
selected={false}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByText(/audience/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/audience/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/priority/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes onClick when the row is clicked", async () => {
|
it("invokes onClick when the row is clicked", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { Group, Text, UnstyledButton } from "@mantine/core";
|
import { Text, UnstyledButton } from "@mantine/core";
|
||||||
import type {
|
import type {
|
||||||
Resource,
|
Resource,
|
||||||
ResourceTemplate,
|
ResourceTemplate,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
|
||||||
|
|
||||||
export interface ResourceListItemProps {
|
export interface ResourceListItemProps {
|
||||||
resource: Resource | ResourceTemplate;
|
resource: Resource | ResourceTemplate;
|
||||||
|
|
@ -11,28 +10,11 @@ export interface ResourceListItemProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RowGroup = Group.withProps({
|
|
||||||
gap: "xs",
|
|
||||||
wrap: "wrap",
|
|
||||||
justify: "space-between",
|
|
||||||
});
|
|
||||||
|
|
||||||
const BadgeGroup = Group.withProps({
|
|
||||||
gap: "xs",
|
|
||||||
wrap: "wrap",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ResourceListItem({
|
export function ResourceListItem({
|
||||||
resource,
|
resource,
|
||||||
selected,
|
selected,
|
||||||
onClick,
|
onClick,
|
||||||
}: ResourceListItemProps) {
|
}: ResourceListItemProps) {
|
||||||
const audience = resource.annotations?.audience;
|
|
||||||
const priority = resource.annotations?.priority;
|
|
||||||
const showBadges =
|
|
||||||
(audience !== undefined && audience.length > 0) || priority !== undefined;
|
|
||||||
const label = <Text fw={500}>{resource.title ?? resource.name}</Text>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
w="100%"
|
w="100%"
|
||||||
|
|
@ -41,21 +23,7 @@ export function ResourceListItem({
|
||||||
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
|
bg={selected ? "var(--mantine-primary-color-light)" : undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{showBadges ? (
|
<Text fw={500}>{resource.title ?? resource.name}</Text>
|
||||||
<RowGroup>
|
|
||||||
{label}
|
|
||||||
<BadgeGroup>
|
|
||||||
{audience !== undefined && audience.length > 0 && (
|
|
||||||
<AnnotationBadge facet="audience" value={audience} />
|
|
||||||
)}
|
|
||||||
{priority !== undefined && (
|
|
||||||
<AnnotationBadge facet="priority" value={priority} />
|
|
||||||
)}
|
|
||||||
</BadgeGroup>
|
|
||||||
</RowGroup>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { useRef } from "react";
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
|
import { fn, userEvent, within } from "storybook/test";
|
||||||
|
import { AppsScreen, type AppsScreenProps } from "./AppsScreen";
|
||||||
|
import type {
|
||||||
|
AppRendererHandle,
|
||||||
|
BridgeFactory,
|
||||||
|
} from "../../elements/AppRenderer/AppRenderer";
|
||||||
|
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||||
|
|
||||||
|
const PLACEHOLDER_SANDBOX = "data:text/html,<title>Mock%20Sandbox</title>";
|
||||||
|
|
||||||
|
function createMockBridge(): AppBridge {
|
||||||
|
return {
|
||||||
|
sendToolInput: async () => {},
|
||||||
|
sendToolResult: async () => {},
|
||||||
|
sendToolCancelled: async () => {},
|
||||||
|
teardownResource: async () => ({}),
|
||||||
|
close: async () => {},
|
||||||
|
} as unknown as AppBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
const okBridgeFactory: BridgeFactory = () => createMockBridge();
|
||||||
|
|
||||||
|
const cohortApp: Tool = {
|
||||||
|
name: "get-cohort-data",
|
||||||
|
title: "Cohort Data",
|
||||||
|
description: "Returns cohort retention heatmap data.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
metric: { type: "string", description: "retention | engagement" },
|
||||||
|
periodType: { type: "string", description: "daily | weekly | monthly" },
|
||||||
|
cohortCount: { type: "number", description: "Cohorts to render" },
|
||||||
|
maxPeriods: { type: "number", description: "Periods per cohort" },
|
||||||
|
},
|
||||||
|
required: ["metric", "periodType"],
|
||||||
|
},
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/cohort-heatmap" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const weatherApp: Tool = {
|
||||||
|
name: "weather-widget",
|
||||||
|
title: "Weather Widget",
|
||||||
|
description: "Live weather and a five-day forecast for any city.",
|
||||||
|
icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
city: { type: "string", description: "City name" },
|
||||||
|
},
|
||||||
|
required: ["city"],
|
||||||
|
},
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashboardApp: Tool = {
|
||||||
|
name: "ops-dashboard",
|
||||||
|
title: "Ops Dashboard",
|
||||||
|
description: "Current operational status across services.",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleApps: Tool[] = [cohortApp, weatherApp, dashboardApp];
|
||||||
|
|
||||||
|
const meta: Meta<typeof AppsScreen> = {
|
||||||
|
title: "Screens/AppsScreen",
|
||||||
|
component: AppsScreen,
|
||||||
|
parameters: { layout: "fullscreen" },
|
||||||
|
args: {
|
||||||
|
sandboxPath: PLACEHOLDER_SANDBOX,
|
||||||
|
bridgeFactory: okBridgeFactory,
|
||||||
|
listChanged: false,
|
||||||
|
onRefreshList: fn(),
|
||||||
|
onSelectApp: fn(),
|
||||||
|
onOpenApp: fn(),
|
||||||
|
onCloseApp: fn(),
|
||||||
|
},
|
||||||
|
// Each story uses its own ref so AppRenderer's imperative handle gets a
|
||||||
|
// fresh slot per render (Storybook may keep the canvas mounted across
|
||||||
|
// arg edits, but the ref itself is owned by the wrapping component).
|
||||||
|
render: function StoryRender(args: AppsScreenProps) {
|
||||||
|
const ref = useRef<AppRendererHandle>(null);
|
||||||
|
return <AppsScreen {...args} rendererRef={ref} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof AppsScreen>;
|
||||||
|
|
||||||
|
async function selectByLabel(canvasElement: HTMLElement, label: string) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(await canvas.findByText(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickByName(canvasElement: HTMLElement, name: RegExp) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(await canvas.findByRole("button", { name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NoSelection: Story = {
|
||||||
|
args: { tools: sampleApps },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppSelected: Story = {
|
||||||
|
args: { tools: sampleApps },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await selectByLabel(canvasElement, "Cohort Data");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppRunning: Story = {
|
||||||
|
args: { tools: sampleApps },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await selectByLabel(canvasElement, "Weather Widget");
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const cityField = await canvas.findByRole("textbox", { name: /city/i });
|
||||||
|
await userEvent.type(cityField, "Reykjavik");
|
||||||
|
await clickByName(canvasElement, /open app/i);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppRunningMaximized: Story = {
|
||||||
|
args: { tools: sampleApps },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await selectByLabel(canvasElement, "Weather Widget");
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const cityField = await canvas.findByRole("textbox", { name: /city/i });
|
||||||
|
await userEvent.type(cityField, "Reykjavik");
|
||||||
|
await clickByName(canvasElement, /open app/i);
|
||||||
|
await userEvent.click(await canvas.findByLabelText("Maximize"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoFieldsApp: Story = {
|
||||||
|
args: { tools: sampleApps },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
await selectByLabel(canvasElement, "Ops Dashboard");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithListChanged: Story = {
|
||||||
|
args: { tools: sampleApps, listChanged: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { tools: [] },
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
|
import {
|
||||||
|
renderWithMantine,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
} from "../../../test/renderWithMantine";
|
||||||
|
import { AppsScreen } from "./AppsScreen";
|
||||||
|
import type {
|
||||||
|
AppRendererHandle,
|
||||||
|
BridgeFactory,
|
||||||
|
} from "../../elements/AppRenderer/AppRenderer";
|
||||||
|
|
||||||
|
const noFieldsApp: Tool = {
|
||||||
|
name: "ops",
|
||||||
|
title: "Ops Dashboard",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldedApp: Tool = {
|
||||||
|
name: "weather",
|
||||||
|
title: "Weather Widget",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
city: { type: "string", description: "City name" },
|
||||||
|
},
|
||||||
|
required: ["city"],
|
||||||
|
},
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const cohortApp: Tool = {
|
||||||
|
name: "cohorts",
|
||||||
|
title: "Cohort Data",
|
||||||
|
description: "Cohort retention",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { metric: { type: "string" } },
|
||||||
|
},
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/cohorts" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const okBridgeFactory: BridgeFactory = () =>
|
||||||
|
({
|
||||||
|
sendToolInput: async () => {},
|
||||||
|
sendToolResult: async () => {},
|
||||||
|
sendToolCancelled: async () => {},
|
||||||
|
teardownResource: async () => ({}),
|
||||||
|
close: async () => {},
|
||||||
|
}) as unknown as AppBridge;
|
||||||
|
|
||||||
|
function buildProps(overrides: Partial<Parameters<typeof AppsScreen>[0]> = {}) {
|
||||||
|
return {
|
||||||
|
tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[],
|
||||||
|
listChanged: false,
|
||||||
|
// happy-dom would otherwise try to fetch the iframe `src` over the
|
||||||
|
// network. A data URL keeps the AppRenderer mountable without leaving
|
||||||
|
// the test environment.
|
||||||
|
sandboxPath: "data:text/html,<title>sandbox</title>",
|
||||||
|
bridgeFactory: okBridgeFactory,
|
||||||
|
rendererRef: createRef<AppRendererHandle>(),
|
||||||
|
onRefreshList: vi.fn(),
|
||||||
|
onSelectApp: vi.fn(),
|
||||||
|
onOpenApp: vi.fn(),
|
||||||
|
onCloseApp: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AppsScreen", () => {
|
||||||
|
it("renders the empty selection state", () => {
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
|
expect(screen.getByText("Select an app to view details")).toBeVisible();
|
||||||
|
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'No apps available' when the tool list is empty", () => {
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps({ tools: [] })} />);
|
||||||
|
expect(screen.getByText("No apps available")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters the list via the search input", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
|
await user.type(screen.getByPlaceholderText("Search apps..."), "weather");
|
||||||
|
expect(screen.getByText("Weather Widget")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Ops Dashboard")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'No matching apps' when search yields no results", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
|
await user.type(screen.getByPlaceholderText("Search apps..."), "zzz");
|
||||||
|
expect(screen.getByText("No matching apps")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the detail panel when a fielded app is selected", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelectApp = vi.fn();
|
||||||
|
const onOpenApp = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<AppsScreen {...buildProps({ onSelectApp, onOpenApp })} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText("Weather Widget"));
|
||||||
|
expect(onSelectApp).toHaveBeenCalledWith("weather");
|
||||||
|
expect(onOpenApp).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Open App/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-launches a no-fields app on selection", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onOpenApp = vi.fn();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps({ onOpenApp })} />);
|
||||||
|
await user.click(screen.getByText("Ops Dashboard"));
|
||||||
|
expect(onOpenApp).toHaveBeenCalledWith("ops", {});
|
||||||
|
// Renderer iframe replaces the form; Open App button is gone.
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Open App/ }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onOpenApp with form values when Open App is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onOpenApp = vi.fn();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps({ onOpenApp })} />);
|
||||||
|
await user.click(screen.getByText("Weather Widget"));
|
||||||
|
const cityField = screen.getByRole("textbox", { name: /city/i });
|
||||||
|
await user.type(cityField, "Reykjavik");
|
||||||
|
await user.click(screen.getByRole("button", { name: /Open App/ }));
|
||||||
|
expect(onOpenApp).toHaveBeenCalledWith("weather", { city: "Reykjavik" });
|
||||||
|
expect(screen.getByTitle("Weather Widget")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the input form when 'Back to Input' is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
|
await user.click(screen.getByText("Weather Widget"));
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /city/i }),
|
||||||
|
"Reykjavik",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /Open App/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /Back to Input/ }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Open App/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show 'Back to Input' for a no-fields app", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
|
await user.click(screen.getByText("Ops Dashboard"));
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Back to Input/ }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles maximize, hiding the sidebar", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
|
await user.click(screen.getByText("Ops Dashboard"));
|
||||||
|
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByLabelText("Maximize"));
|
||||||
|
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByLabelText("Restore"));
|
||||||
|
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCloseApp and clears selection on Close", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onCloseApp = vi.fn();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps({ onCloseApp })} />);
|
||||||
|
await user.click(screen.getByText("Ops Dashboard"));
|
||||||
|
await user.click(screen.getByLabelText("Close"));
|
||||||
|
expect(onCloseApp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByText("Select an app to view details")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores re-clicking the same app (no duplicate onSelectApp)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelectApp = vi.fn();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps({ onSelectApp })} />);
|
||||||
|
// After the first click "Weather Widget" appears both in the sidebar
|
||||||
|
// list item and the right-pane header, so target the sidebar entry
|
||||||
|
// explicitly via the list-item button role.
|
||||||
|
const listItem = screen.getByRole("button", { name: /Weather Widget/ });
|
||||||
|
await user.click(listItem);
|
||||||
|
await user.click(listItem);
|
||||||
|
expect(onSelectApp).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets form state when switching to a different app", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||||
|
await user.click(screen.getByText("Weather Widget"));
|
||||||
|
await user.type(
|
||||||
|
screen.getByRole("textbox", { name: /city/i }),
|
||||||
|
"Reykjavik",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText("Cohort Data"));
|
||||||
|
// Cohort form is fresh; Reykjavik (the previous tool's input) is gone.
|
||||||
|
expect(screen.queryByDisplayValue("Reykjavik")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Open App/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the ListChangedIndicator when listChanged is true", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRefreshList = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<AppsScreen {...buildProps({ listChanged: true, onRefreshList })} />,
|
||||||
|
);
|
||||||
|
// The indicator and toolbar both render "Refresh" buttons; the indicator
|
||||||
|
// is a sibling of the "List updated" label, so target it via that label.
|
||||||
|
expect(screen.getByText("List updated")).toBeInTheDocument();
|
||||||
|
const refreshButtons = screen.getAllByRole("button", { name: "Refresh" });
|
||||||
|
expect(refreshButtons).toHaveLength(2);
|
||||||
|
await user.click(refreshButtons[0]);
|
||||||
|
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onRefreshList when the toolbar Refresh button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRefreshList = vi.fn();
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps({ onRefreshList })} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
|
expect(onRefreshList).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the tool icon next to the header title when present", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const iconSrc = "data:image/svg+xml,%3Csvg/%3E";
|
||||||
|
const iconedApp: Tool = {
|
||||||
|
name: "weather-with-icon",
|
||||||
|
title: "Weather (Iconed)",
|
||||||
|
icons: [{ src: iconSrc }],
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/weather-iconed" } },
|
||||||
|
};
|
||||||
|
renderWithMantine(<AppsScreen {...buildProps({ tools: [iconedApp] })} />);
|
||||||
|
await user.click(screen.getByText("Weather (Iconed)"));
|
||||||
|
const headerImg = screen
|
||||||
|
.getAllByRole("presentation")
|
||||||
|
.find((img) => img.getAttribute("src") === iconSrc);
|
||||||
|
expect(headerImg).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores selection of an unknown tool name (defensive)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelectApp = vi.fn();
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<AppsScreen {...buildProps({ onSelectApp })} />,
|
||||||
|
);
|
||||||
|
// Click an item, then re-render with a tools list that no longer
|
||||||
|
// contains it: the selection state stays put, but a follow-up click
|
||||||
|
// on the same name no-ops because the lookup fails.
|
||||||
|
await user.click(screen.getByText("Weather Widget"));
|
||||||
|
rerender(
|
||||||
|
<AppsScreen
|
||||||
|
{...buildProps({ onSelectApp, tools: [noFieldsApp, cohortApp] })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Sidebar no longer shows Weather Widget; the right pane falls back
|
||||||
|
// to the empty selection state since the lookup misses.
|
||||||
|
expect(
|
||||||
|
within(screen.getByText("MCP Apps (2)").parentElement!).queryByText(
|
||||||
|
"Weather Widget",
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Select an app to view details")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
276
clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
Normal file
276
clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { useState, type Ref } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
MdArrowBack,
|
||||||
|
MdClose,
|
||||||
|
MdFullscreen,
|
||||||
|
MdFullscreenExit,
|
||||||
|
} from "react-icons/md";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import {
|
||||||
|
AppRenderer,
|
||||||
|
type AppRendererHandle,
|
||||||
|
type BridgeFactory,
|
||||||
|
} from "../../elements/AppRenderer/AppRenderer";
|
||||||
|
import { AppDetailPanel } from "../../groups/AppDetailPanel/AppDetailPanel";
|
||||||
|
import { AppControls } from "../../groups/AppControls/AppControls";
|
||||||
|
import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils";
|
||||||
|
|
||||||
|
export interface AppsScreenProps {
|
||||||
|
tools: Tool[];
|
||||||
|
listChanged: boolean;
|
||||||
|
sandboxPath: string;
|
||||||
|
bridgeFactory: BridgeFactory;
|
||||||
|
rendererRef: Ref<AppRendererHandle>;
|
||||||
|
onRefreshList: () => void;
|
||||||
|
onSelectApp: (name: string) => void;
|
||||||
|
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||||
|
onCloseApp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScreenLayout = Flex.withProps({
|
||||||
|
variant: "screen",
|
||||||
|
h: "calc(100vh - var(--app-shell-header-height, 0px))",
|
||||||
|
gap: "md",
|
||||||
|
p: "xl",
|
||||||
|
align: "flex-start",
|
||||||
|
});
|
||||||
|
|
||||||
|
const Sidebar = Stack.withProps({
|
||||||
|
w: 340,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
const SidebarCard = Card.withProps({
|
||||||
|
withBorder: true,
|
||||||
|
padding: "lg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContentCard = Card.withProps({
|
||||||
|
withBorder: true,
|
||||||
|
padding: "lg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const EmptyState = Text.withProps({
|
||||||
|
c: "dimmed",
|
||||||
|
ta: "center",
|
||||||
|
py: "xl",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
wrap: "nowrap",
|
||||||
|
gap: "sm",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderLabel = Group.withProps({
|
||||||
|
gap: "sm",
|
||||||
|
wrap: "nowrap",
|
||||||
|
align: "center",
|
||||||
|
flex: 1,
|
||||||
|
miw: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderIcon = Image.withProps({
|
||||||
|
w: 24,
|
||||||
|
h: 24,
|
||||||
|
fit: "contain",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderTitle = Text.withProps({
|
||||||
|
fw: 600,
|
||||||
|
size: "lg",
|
||||||
|
truncate: true,
|
||||||
|
flex: 1,
|
||||||
|
miw: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderActions = Group.withProps({
|
||||||
|
gap: "xs",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const RendererFrame = Stack.withProps({
|
||||||
|
flex: 1,
|
||||||
|
miw: 0,
|
||||||
|
mih: 0,
|
||||||
|
gap: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContentStack = Stack.withProps({
|
||||||
|
gap: "md",
|
||||||
|
h: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AppsScreen({
|
||||||
|
tools,
|
||||||
|
listChanged,
|
||||||
|
sandboxPath,
|
||||||
|
bridgeFactory,
|
||||||
|
rendererRef,
|
||||||
|
onRefreshList,
|
||||||
|
onSelectApp,
|
||||||
|
onOpenApp,
|
||||||
|
onCloseApp,
|
||||||
|
}: AppsScreenProps) {
|
||||||
|
const [selectedAppName, setSelectedAppName] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [maximized, setMaximized] = useState(false);
|
||||||
|
|
||||||
|
const selectedTool = selectedAppName
|
||||||
|
? tools.find((t) => t.name === selectedAppName)
|
||||||
|
: undefined;
|
||||||
|
const selectedHasFields = selectedTool ? hasInputFields(selectedTool) : false;
|
||||||
|
|
||||||
|
function handleSelect(name: string) {
|
||||||
|
if (name === selectedAppName) return;
|
||||||
|
const next = tools.find((t) => t.name === name);
|
||||||
|
if (!next) return;
|
||||||
|
setSelectedAppName(name);
|
||||||
|
setFormValues({});
|
||||||
|
setMaximized(false);
|
||||||
|
onSelectApp(name);
|
||||||
|
// No-input apps auto-launch on selection so the user lands directly in
|
||||||
|
// the running view; apps with fields wait for the explicit Open App click.
|
||||||
|
if (!hasInputFields(next)) {
|
||||||
|
setRunning(true);
|
||||||
|
onOpenApp(name, {});
|
||||||
|
} else {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
if (!selectedTool) return;
|
||||||
|
setRunning(true);
|
||||||
|
onOpenApp(selectedTool.name, formValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setRunning(false);
|
||||||
|
setSelectedAppName(undefined);
|
||||||
|
setFormValues({});
|
||||||
|
setMaximized(false);
|
||||||
|
onCloseApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackToInput() {
|
||||||
|
setRunning(false);
|
||||||
|
setMaximized(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenLayout>
|
||||||
|
{!maximized && (
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarCard>
|
||||||
|
<AppControls
|
||||||
|
tools={tools}
|
||||||
|
selectedName={selectedAppName}
|
||||||
|
listChanged={listChanged}
|
||||||
|
onRefreshList={onRefreshList}
|
||||||
|
onSelectApp={handleSelect}
|
||||||
|
/>
|
||||||
|
</SidebarCard>
|
||||||
|
</Sidebar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ContentCard flex={1} h="100%">
|
||||||
|
{selectedTool ? (
|
||||||
|
<ContentStack>
|
||||||
|
<HeaderRow>
|
||||||
|
<HeaderLabel>
|
||||||
|
{selectedTool.icons?.[0]?.src && (
|
||||||
|
<HeaderIcon src={selectedTool.icons[0].src} alt="" />
|
||||||
|
)}
|
||||||
|
<HeaderTitle>
|
||||||
|
{resolveDisplayLabel(selectedTool.name, selectedTool.title)}
|
||||||
|
</HeaderTitle>
|
||||||
|
</HeaderLabel>
|
||||||
|
<HeaderActions>
|
||||||
|
{running && selectedHasFields && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<MdArrowBack aria-hidden size={16} />}
|
||||||
|
onClick={handleBackToInput}
|
||||||
|
>
|
||||||
|
Back to Input
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{running && (
|
||||||
|
<Tooltip label={maximized ? "Restore" : "Maximize"}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => setMaximized((m) => !m)}
|
||||||
|
aria-label={maximized ? "Restore" : "Maximize"}
|
||||||
|
>
|
||||||
|
{maximized ? (
|
||||||
|
<MdFullscreenExit aria-hidden size={20} />
|
||||||
|
) : (
|
||||||
|
<MdFullscreen aria-hidden size={20} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Close">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<MdClose aria-hidden size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</HeaderActions>
|
||||||
|
</HeaderRow>
|
||||||
|
{running ? (
|
||||||
|
<RendererFrame>
|
||||||
|
{/* Keying by name forces the renderer to remount when the
|
||||||
|
selected app changes, ensuring a fresh bridge and iframe
|
||||||
|
rather than reusing the previous app's transport. */}
|
||||||
|
<AppRenderer
|
||||||
|
key={selectedTool.name}
|
||||||
|
sandboxPath={sandboxPath}
|
||||||
|
tool={selectedTool}
|
||||||
|
bridgeFactory={bridgeFactory}
|
||||||
|
ref={rendererRef}
|
||||||
|
/>
|
||||||
|
</RendererFrame>
|
||||||
|
) : (
|
||||||
|
// `isOpening` is always false here because `handleOpen`
|
||||||
|
// synchronously flips `running` to true, swapping in the
|
||||||
|
// AppRenderer before the panel could render its loading
|
||||||
|
// state. The prop stays in `AppDetailPanel`'s API for
|
||||||
|
// standalone use (the `Opening` story) and for Phase 3
|
||||||
|
// wiring, where a managed-state hook can hold the panel
|
||||||
|
// in a pending state across an awaited `tools/call`.
|
||||||
|
<AppDetailPanel
|
||||||
|
tool={selectedTool}
|
||||||
|
formValues={formValues}
|
||||||
|
isOpening={false}
|
||||||
|
onFormChange={setFormValues}
|
||||||
|
onOpenApp={handleOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContentStack>
|
||||||
|
) : (
|
||||||
|
<EmptyState>Select an app to view details</EmptyState>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
</ScreenLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ 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,
|
||||||
|
|
@ -13,9 +14,54 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { fn } from "storybook/test";
|
import { fn } from "storybook/test";
|
||||||
import { InspectorView } from "./InspectorView";
|
import { InspectorView } from "./InspectorView";
|
||||||
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 { longToolList as demoRegularTools } from "../../screens/ToolsScreen/ToolsScreen.fixtures";
|
||||||
|
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||||
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
|
||||||
|
|
||||||
|
// MCP App tools — `isAppTool` detects these via `_meta.ui.resourceUri`,
|
||||||
|
// so they get filtered into the Apps tab while still appearing on Tools.
|
||||||
|
const demoApps: Tool[] = [
|
||||||
|
{
|
||||||
|
name: "get-cohort-data",
|
||||||
|
title: "Cohort Data",
|
||||||
|
description: "Cohort retention heatmap with adjustable period and metric.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
metric: { type: "string", description: "retention | engagement" },
|
||||||
|
periodType: { type: "string", description: "daily | weekly | monthly" },
|
||||||
|
cohortCount: { type: "number", description: "Cohorts to render" },
|
||||||
|
maxPeriods: { type: "number", description: "Periods per cohort" },
|
||||||
|
},
|
||||||
|
required: ["metric", "periodType"],
|
||||||
|
},
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/cohort-heatmap" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weather-widget",
|
||||||
|
title: "Weather Widget",
|
||||||
|
description: "Live weather and a five-day forecast for any city.",
|
||||||
|
icons: [{ src: SUN_ICON_SVG, mimeType: "image/svg+xml" }],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
city: { type: "string", description: "City name" },
|
||||||
|
},
|
||||||
|
required: ["city"],
|
||||||
|
},
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/weather" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ops-dashboard",
|
||||||
|
title: "Ops Dashboard",
|
||||||
|
description: "Current operational status across services.",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const demoTools: Tool[] = [...demoApps, ...demoRegularTools];
|
||||||
|
|
||||||
const demoServers: ServerEntry[] = [
|
const demoServers: ServerEntry[] = [
|
||||||
{
|
{
|
||||||
id: "5e8c3d1f-2a4b-4c6d-8e7f-1a2b3c4d5e6f",
|
id: "5e8c3d1f-2a4b-4c6d-8e7f-1a2b3c4d5e6f",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
import type { ServerEntry } from "@inspector/core/mcp/types.js";
|
||||||
import {
|
import {
|
||||||
renderWithMantine,
|
renderWithMantine,
|
||||||
|
|
@ -127,6 +128,53 @@ describe("InspectorView", () => {
|
||||||
expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Alpha").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => {
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const opsApp: Tool = {
|
||||||
|
name: "ops",
|
||||||
|
title: "Ops Dashboard",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "ui://apps/ops" } },
|
||||||
|
};
|
||||||
|
// Plain (non-app) tool plus a tool with a malformed UI resource URI
|
||||||
|
// exercise both branches of the appTools filter: the non-app drop and
|
||||||
|
// the try/catch around `isAppTool` for malformed metadata.
|
||||||
|
const plainTool: Tool = {
|
||||||
|
name: "shell.exec",
|
||||||
|
title: "Run Shell",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
};
|
||||||
|
const malformedAppTool: Tool = {
|
||||||
|
name: "broken",
|
||||||
|
title: "Broken App",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
_meta: { ui: { resourceUri: "not-a-ui-uri" } },
|
||||||
|
};
|
||||||
|
renderWithMantine(
|
||||||
|
<InspectorView
|
||||||
|
{...baseProps}
|
||||||
|
servers={[sampleServer]}
|
||||||
|
tools={[opsApp, plainTool, malformedAppTool]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("switch"));
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByRole("switch")).toBeChecked();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
const tabSelect = await screen.findByDisplayValue("Servers");
|
||||||
|
await user.click(tabSelect);
|
||||||
|
await user.click(await screen.findByText("Apps"));
|
||||||
|
expect(screen.getByText("MCP Apps (1)")).toBeInTheDocument();
|
||||||
|
// Auto-launch on selection mounts the AppRenderer, which invokes the
|
||||||
|
// stub bridge factory wired in InspectorView.
|
||||||
|
await user.click(screen.getByText("Ops Dashboard"));
|
||||||
|
expect(screen.getByTitle("Ops Dashboard")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("toggles autoScroll on the Logs screen after connecting", async () => {
|
it("toggles autoScroll on the Logs screen after connecting", async () => {
|
||||||
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,22 @@ import type {
|
||||||
Task,
|
Task,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
import type {
|
import type {
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
InspectorResourceSubscription,
|
InspectorResourceSubscription,
|
||||||
MessageEntry,
|
MessageEntry,
|
||||||
ServerEntry,
|
ServerEntry,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
|
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
||||||
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
||||||
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
||||||
import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen";
|
import { ToolsScreen } from "../../screens/ToolsScreen/ToolsScreen";
|
||||||
|
import { AppsScreen } from "../../screens/AppsScreen/AppsScreen";
|
||||||
|
import type {
|
||||||
|
AppRendererHandle,
|
||||||
|
BridgeFactory,
|
||||||
|
} from "../../elements/AppRenderer/AppRenderer";
|
||||||
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
|
import { PromptsScreen } from "../../screens/PromptsScreen/PromptsScreen";
|
||||||
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
import { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||||
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
||||||
|
|
@ -31,6 +38,7 @@ const SERVERS_TAB = "Servers";
|
||||||
const ALL_TABS: string[] = [
|
const ALL_TABS: string[] = [
|
||||||
SERVERS_TAB,
|
SERVERS_TAB,
|
||||||
"Tools",
|
"Tools",
|
||||||
|
"Apps",
|
||||||
"Prompts",
|
"Prompts",
|
||||||
"Resources",
|
"Resources",
|
||||||
"Tasks",
|
"Tasks",
|
||||||
|
|
@ -38,6 +46,20 @@ const ALL_TABS: string[] = [
|
||||||
"History",
|
"History",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Demo stub: Phase 3 wiring will replace this with a factory derived from
|
||||||
|
// the active MCP `Client`. Apps are detected from the tools list, so the
|
||||||
|
// "Apps" tab is exposed whenever the server advertises tools capability —
|
||||||
|
// the screen itself receives only the already-filtered subset.
|
||||||
|
const STUB_SANDBOX_PATH = "about:blank";
|
||||||
|
const stubBridgeFactory: BridgeFactory = () =>
|
||||||
|
({
|
||||||
|
sendToolInput: async () => {},
|
||||||
|
sendToolResult: async () => {},
|
||||||
|
sendToolCancelled: async () => {},
|
||||||
|
teardownResource: async () => ({}),
|
||||||
|
close: async () => {},
|
||||||
|
}) as unknown as AppBridge;
|
||||||
|
|
||||||
const SCREEN_ENTER_MS = 350;
|
const SCREEN_ENTER_MS = 350;
|
||||||
const SCREEN_EXIT_MS = 250;
|
const SCREEN_EXIT_MS = 250;
|
||||||
|
|
||||||
|
|
@ -152,6 +174,19 @@ export function InspectorView({
|
||||||
// toggles can cancel the previous attempt before it resolves and
|
// toggles can cancel the previous attempt before it resolves and
|
||||||
// overwrites the new server's state.
|
// overwrites the new server's state.
|
||||||
const handshakeTimer = useRef<number | undefined>(undefined);
|
const handshakeTimer = useRef<number | undefined>(undefined);
|
||||||
|
const appRendererRef = useRef<AppRendererHandle>(null);
|
||||||
|
const appTools = useMemo<Tool[]>(() => {
|
||||||
|
return tools.filter((tool) => {
|
||||||
|
try {
|
||||||
|
return isAppTool(tool);
|
||||||
|
} catch {
|
||||||
|
// `isAppTool` throws on malformed `_meta.ui.resourceUri`; tolerate
|
||||||
|
// mixed-validity tool lists by skipping the bad tool rather than
|
||||||
|
// halting the filter (and hiding every following App).
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [tools]);
|
||||||
|
|
||||||
// The view is the single source of truth for connection state. Any
|
// The view is the single source of truth for connection state. Any
|
||||||
// `connection` field on incoming `serversInput` items is intentionally
|
// `connection` field on incoming `serversInput` items is intentionally
|
||||||
|
|
@ -290,6 +325,19 @@ export function InspectorView({
|
||||||
onCallTool={noop}
|
onCallTool={noop}
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
|
<ScreenStage active={activeTab === "Apps"}>
|
||||||
|
<AppsScreen
|
||||||
|
tools={appTools}
|
||||||
|
listChanged={false}
|
||||||
|
sandboxPath={STUB_SANDBOX_PATH}
|
||||||
|
bridgeFactory={stubBridgeFactory}
|
||||||
|
rendererRef={appRendererRef}
|
||||||
|
onRefreshList={noop}
|
||||||
|
onSelectApp={noop}
|
||||||
|
onOpenApp={noop}
|
||||||
|
onCloseApp={noop}
|
||||||
|
/>
|
||||||
|
</ScreenStage>
|
||||||
<ScreenStage active={activeTab === "Prompts"}>
|
<ScreenStage active={activeTab === "Prompts"}>
|
||||||
<PromptsScreen
|
<PromptsScreen
|
||||||
prompts={prompts}
|
prompts={prompts}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export type InspectorTab =
|
export type InspectorTab =
|
||||||
| "tools"
|
| "tools"
|
||||||
|
| "apps"
|
||||||
| "prompts"
|
| "prompts"
|
||||||
| "resources"
|
| "resources"
|
||||||
| "logs"
|
| "logs"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { resolveDisplayLabel } from "./toolUtils";
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { hasInputFields, resolveDisplayLabel } from "./toolUtils";
|
||||||
|
|
||||||
describe("resolveDisplayLabel", () => {
|
describe("resolveDisplayLabel", () => {
|
||||||
it("returns the title when provided", () => {
|
it("returns the title when provided", () => {
|
||||||
|
|
@ -18,3 +19,31 @@ describe("resolveDisplayLabel", () => {
|
||||||
expect(resolveDisplayLabel("send_message", "")).toBe("");
|
expect(resolveDisplayLabel("send_message", "")).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasInputFields", () => {
|
||||||
|
const baseTool = (inputSchema: Tool["inputSchema"]): Tool => ({
|
||||||
|
name: "t",
|
||||||
|
inputSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when properties is missing", () => {
|
||||||
|
expect(hasInputFields(baseTool({ type: "object" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when properties is empty", () => {
|
||||||
|
expect(hasInputFields(baseTool({ type: "object", properties: {} }))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when properties has at least one entry", () => {
|
||||||
|
expect(
|
||||||
|
hasInputFields(
|
||||||
|
baseTool({
|
||||||
|
type: "object",
|
||||||
|
properties: { x: { type: "string" } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the display label for an MCP entity that follows the BaseMetadata
|
* Returns the display label for an MCP entity that follows the BaseMetadata
|
||||||
* shape (Tool, Prompt, Resource): the optional `title` if provided, else the
|
* shape (Tool, Prompt, Resource): the optional `title` if provided, else the
|
||||||
|
|
@ -7,3 +9,13 @@
|
||||||
export function resolveDisplayLabel(name: string, title?: string): string {
|
export function resolveDisplayLabel(name: string, title?: string): string {
|
||||||
return title ?? name;
|
return title ?? name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the tool's input schema declares at least one property — used by
|
||||||
|
* App-flow callers to decide whether to render a form or auto-launch. Kept in
|
||||||
|
* one place so the definition of "has fields" stays consistent if it ever
|
||||||
|
* grows to consider `additionalProperties`, `anyOf`, etc.
|
||||||
|
*/
|
||||||
|
export function hasInputFields(tool: Tool): boolean {
|
||||||
|
return Object.keys(tool.inputSchema.properties ?? {}).length > 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue