* 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
3 changed files with 1210 additions and 164 deletions
|
|
@ -3,6 +3,7 @@ import type {
|
|||
Resource,
|
||||
ResourceTemplate,
|
||||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
InspectorResourceSubscription,
|
||||
|
|
@ -13,9 +14,54 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|||
import { fn } from "storybook/test";
|
||||
import { InspectorView } from "./InspectorView";
|
||||
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";
|
||||
|
||||
// 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[] = [
|
||||
{
|
||||
id: "5e8c3d1f-2a4b-4c6d-8e7f-1a2b3c4d5e6f",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
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 {
|
||||
renderWithMantine,
|
||||
|
|
@ -127,6 +128,53 @@ describe("InspectorView", () => {
|
|||
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 () => {
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.1);
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -9,15 +9,22 @@ import type {
|
|||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
InspectorResourceSubscription,
|
||||
MessageEntry,
|
||||
ServerEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { isAppTool } from "@inspector/core/mcp/apps.js";
|
||||
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
|
||||
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
|
||||
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 { ResourcesScreen } from "../../screens/ResourcesScreen/ResourcesScreen";
|
||||
import { LoggingScreen } from "../../screens/LoggingScreen/LoggingScreen";
|
||||
|
|
@ -31,6 +38,7 @@ const SERVERS_TAB = "Servers";
|
|||
const ALL_TABS: string[] = [
|
||||
SERVERS_TAB,
|
||||
"Tools",
|
||||
"Apps",
|
||||
"Prompts",
|
||||
"Resources",
|
||||
"Tasks",
|
||||
|
|
@ -38,6 +46,20 @@ const ALL_TABS: string[] = [
|
|||
"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_EXIT_MS = 250;
|
||||
|
||||
|
|
@ -152,6 +174,19 @@ export function InspectorView({
|
|||
// toggles can cancel the previous attempt before it resolves and
|
||||
// overwrites the new server's state.
|
||||
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
|
||||
// `connection` field on incoming `serversInput` items is intentionally
|
||||
|
|
@ -290,6 +325,19 @@ export function InspectorView({
|
|||
onCallTool={noop}
|
||||
/>
|
||||
</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"}>
|
||||
<PromptsScreen
|
||||
prompts={prompts}
|
||||
|
|
|
|||
Loading…
Reference in a new issue