feat(apps): AppsScreen and InspectorView wiring (#1263) (#1286)

* 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:
Cliff Hall 2026-05-07 14:18:27 -04:00 • committed by GitHub
commit 5f296589ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1210 additions and 164 deletions

View file

@ -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",

View file

@ -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();

View file

@ -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}