* feat(apps): render running MCP Apps end-to-end (#1370) Wire the v2 Apps screen so clicking Open App actually runs an MCP App. Previously App.tsx stubbed the three load-bearing pieces (sandbox URL, bridge factory, action handlers), so the iframe loaded about:blank and nothing crossed the bridge. - Add useSandboxUrl hook (core/react) to read sandboxUrl from GET /api/config and thread it down as sandboxPath; render an "MCP Apps unavailable" state instead of a blank iframe when absent. - Add createAppBridgeFactory: builds a host-side ext-apps AppBridge over the active client's SDK client, reads the tool's UI resource on the sandbox handshake and pushes it into the proxy, handles openLinks, and connects a PostMessageTransport. - AppRenderer: pass the tool to the bridge factory; buffer tool input/result and release them once the view signals `initialized` (input before result, latest-wins). - Move the AppRenderer handle ref to App.tsx so onOpenApp issues tools/call with the form values and delivers input + result to the running app via the bridge; onCloseApp tears the bridge/iframe down. - Stories: themed sandbox placeholder (visible in light/dark), an echo-bridge running story, and an unavailable story. New unit tests for the factory, the buffering, useSandboxUrl, and the empty state. The listChanged indicator badge wiring is tracked separately in #1402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(apps): make MCP Apps render in dev — single bridge + result passthrough Two bugs kept apps (e.g. NightRider's MIDI Pattern Editor) stuck on an empty shell even though the host fetched the resource and called the tool correctly. 1. StrictMode double-mounted the bridge. The dev-only effect setup→cleanup→ setup built TWO host bridges/transports, double-loaded the sandbox, and raced the app's ui/initialize handshake (it timed out, so `initialized` never fired and buffered input/result were never flushed). AppRenderer now defers bridge disposal to a microtask and, when the effect re-runs synchronously with identical inputs, cancels the disposal and reuses the live bridge — so a single bridge survives the double-invoke. The pending input/result buffer is no longer cleared on a transient cleanup. 2. The SDK rejected the server's result. callTool() validates structuredContent against the tool's outputSchema and throws on a mismatch — but an MCP App is a passthrough: the host must forward whatever the server returned to the running view (which legacy hosts render fine). InspectorClient.callTool gains a `skipOutputValidation` option that routes through client.request() (no wrapper validation); onOpenApp uses it. Verified end-to-end against a real app via Playwright: the editor now renders its grid + pattern data identically to the v1 inspector. Tests: AppRenderer StrictMode single-bridge/no-dispose case; a get_temp_extra test-server fixture (returns an extra structuredContent property) with integration tests covering both the throwing default path and the skipOutputValidation passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps): warn (toast) when an app's result violates its outputSchema The skipOutputValidation passthrough keeps schema-mismatched apps rendering, but that silently hid a real spec problem from server developers. Now the host still renders, but surfaces the mismatch. - InspectorClient.callTool runs the SDK's own Ajv output validation on the skipOutputValidation path NON-fatally and returns the message as `outputValidationError` on the ToolCallInvocation (and the toolCallResultChange event); the result is still delivered. - App.tsx onOpenApp shows a yellow toast when outputValidationError is set, explaining strict MCP clients may not render the app. Tests: integration cases asserting outputValidationError is set for a schema-violating result (get_temp_extra) and unset for a valid one (get_temp). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps): summarize output-schema warning in toast + details modal The previous toast dumped the full (very long) validation error and overflowed. Now the toast shows a one-line summary with a "View validation details" link that opens a modal containing the full error in a read-only, scrollable textarea. - New OutputValidationModal group component (+ story + tests). - App.tsx: toast message is a summary + link; clicking it opens the modal populated with the tool name and the full outputValidationError. Verified via Playwright against the NightRider MIDI editor: toast is concise, link opens the modal with the scrollable details. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(apps): address PR review — theme stability, onError, validation polish Responds to the automated PR review on #1403. - Theme toggle no longer rebuilds a running app's bridge. createAppBridgeFactory reads the resolved theme from the DOM (Mantine's data-mantine-color-scheme) at bridge-build time instead of capturing isDark in the factory deps, so the factory identity is stable across theme flips. (#1) - Wire AppRenderer's onError through AppsScreen -> InspectorView -> App.tsx, surfacing bridge/runtime failures (no client, late rejection, failed tools/call) as a red toast instead of a silent blank iframe. (#2) - Extract validateToolOutput into a pure, unit-tested helper covering the matching / extra-property / missing-structuredContent / error / bad-schema cases (the no-structuredContent branch can't be produced via the SDK server, which rejects it before sending). (#4) - useSandboxUrl aborts its in-flight fetch on unmount (cancelled flag) to avoid setState after unmount. (#5) - Schema-mismatch toast: autoClose:false (details modal is one click away). (note) - AppRenderer: add a state-machine comment block documenting the bridge lifecycle refs. (#6) - inspectorClient: note why the `as CallToolResult` casts are safe. (note) Tests: AppsScreen onError surface; validateToolOutput unit suite; factory theme read from DOM. validate + test:storybook green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(apps): clarify theme attribute + useSandboxUrl fetch comments Re-review follow-ups (non-blocking): - createAppBridgeFactory: note that Mantine's data-mantine-color-scheme is only ever light/dark (auto is resolved before paint), so no `auto` branch is needed and matchMedia only covers an absent attribute. - useSandboxUrl: correct the doc to say it fetches on mount AND re-fetches on baseUrl/authToken change (idempotent), not "once on mount". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
440b194f64
commit
5fb2b503f4
3 changed files with 1683 additions and 66 deletions
|
|
@ -1,8 +1,9 @@
|
|||
import { useRef } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { fn, userEvent, within } from "storybook/test";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { AppsScreen, type AppsScreenProps } from "./AppsScreen";
|
||||
import type {
|
||||
AppRendererHandle,
|
||||
|
|
@ -10,7 +11,18 @@ import type {
|
|||
} from "../../elements/AppRenderer/AppRenderer";
|
||||
import { SUN_ICON_SVG } from "../../../test/fixtures/storyIcons";
|
||||
|
||||
const PLACEHOLDER_SANDBOX = "data:text/html,<title>Mock%20Sandbox</title>";
|
||||
// A self-contained, themed sandbox page so the running-state stories show
|
||||
// visible content in both light and dark mode (Canvas / CanvasText are CSS
|
||||
// system colors that follow `color-scheme`), instead of a blank/white frame.
|
||||
const PLACEHOLDER_SANDBOX =
|
||||
"data:text/html," +
|
||||
encodeURIComponent(
|
||||
`<!doctype html><html><head><meta name="color-scheme" content="light dark">` +
|
||||
`<style>html,body{height:100%;margin:0}` +
|
||||
`body{display:flex;align-items:center;justify-content:center;` +
|
||||
`font-family:system-ui,sans-serif;color:CanvasText;background:Canvas}</style>` +
|
||||
`</head><body><div>Mock app — running in sandbox</div></body></html>`,
|
||||
);
|
||||
|
||||
function createMockBridge(): AppBridge {
|
||||
return {
|
||||
|
|
@ -19,6 +31,8 @@ function createMockBridge(): AppBridge {
|
|||
sendToolCancelled: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as AppBridge;
|
||||
}
|
||||
|
||||
|
|
@ -149,3 +163,95 @@ export const WithListChanged: Story = {
|
|||
export const Empty: Story = {
|
||||
args: { tools: [] },
|
||||
};
|
||||
|
||||
// Drives the real running-state interaction with a fake bridge that echoes
|
||||
// tool input straight back as a tool result. Because the mock sandbox page
|
||||
// never completes the real handshake, the factory synthesizes the view's
|
||||
// `initialized` signal so AppRenderer flushes the buffered input. The echoed
|
||||
// result is surfaced in a status line so the round-trip is observable in
|
||||
// `npm run test:storybook`.
|
||||
export const EchoRunning: Story = {
|
||||
args: { tools: [weatherApp] },
|
||||
render: function EchoRender(args: AppsScreenProps) {
|
||||
const ref = useRef<AppRendererHandle>(null);
|
||||
const [echo, setEcho] = useState<string | null>(null);
|
||||
|
||||
const bridgeFactory: BridgeFactory = useCallback(() => {
|
||||
let onInitialized: (() => void) | undefined;
|
||||
const bridge = {
|
||||
addEventListener: (event: string, handler: () => void) => {
|
||||
if (event === "initialized") onInitialized = handler;
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
sendToolInput: async (params: {
|
||||
arguments?: Record<string, unknown>;
|
||||
}) => {
|
||||
// Echo the input back as a result.
|
||||
await bridge.sendToolResult({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `echo: ${JSON.stringify(params.arguments)}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
sendToolResult: async (result: CallToolResult) => {
|
||||
const first = result.content[0];
|
||||
setEcho(first?.type === "text" ? first.text : "");
|
||||
},
|
||||
sendToolCancelled: async () => {},
|
||||
teardownResource: async () => ({}),
|
||||
close: async () => {},
|
||||
};
|
||||
// Simulate the view finishing initialization shortly after AppRenderer
|
||||
// registers its `initialized` listener; sendToolInput then flushes.
|
||||
setTimeout(() => onInitialized?.(), 20);
|
||||
return bridge as unknown as AppBridge;
|
||||
}, []);
|
||||
|
||||
const onOpenApp = useCallback(
|
||||
(_name: string, formArgs: Record<string, unknown>) => {
|
||||
// Mirror App.tsx: defer one microtask so the renderer's imperative
|
||||
// handle (set during the commit's layout phase) is wired before
|
||||
// pushing input. The renderer buffers it until the view initializes.
|
||||
void Promise.resolve().then(() => ref.current?.sendToolInput(formArgs));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text data-testid="echo-status" p="xs">
|
||||
{echo ?? "no echo yet"}
|
||||
</Text>
|
||||
<AppsScreen
|
||||
{...args}
|
||||
rendererRef={ref}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onOpenApp={onOpenApp}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
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, "Oslo");
|
||||
await clickByName(canvasElement, /open app/i);
|
||||
await waitFor(() =>
|
||||
expect(canvas.getByTestId("echo-status")).toHaveTextContent(/echo:/),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// No sandbox URL available — the screen renders an unavailable state rather
|
||||
// than a blank iframe.
|
||||
export const Unavailable: Story = {
|
||||
args: { tools: sampleApps, sandboxPath: undefined },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText(/MCP Apps are unavailable/i);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -85,6 +85,36 @@ describe("AppsScreen", () => {
|
|||
expect(screen.getByText("MCP Apps (0)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the unavailable state when no sandbox path is provided", () => {
|
||||
renderWithMantine(
|
||||
<AppsScreen {...buildProps({ sandboxPath: undefined })} />,
|
||||
);
|
||||
expect(screen.getByText(/MCP Apps are unavailable/i)).toBeInTheDocument();
|
||||
// The sidebar / app list is not rendered in the unavailable state.
|
||||
expect(screen.queryByText("MCP Apps (3)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a bridge factory failure via onError", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onError = vi.fn();
|
||||
const throwingFactory: BridgeFactory = () => {
|
||||
throw new Error("no connected MCP client");
|
||||
};
|
||||
renderWithMantine(
|
||||
<AppsScreen
|
||||
{...buildProps({ bridgeFactory: throwingFactory, onError })}
|
||||
/>,
|
||||
);
|
||||
// The no-fields app auto-launches on selection, mounting the renderer,
|
||||
// whose effect invokes the factory (which throws → routes to onError).
|
||||
await user.click(screen.getByText("Ops Dashboard"));
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
|
||||
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect((onError.mock.calls[0][0] as Error).message).toContain(
|
||||
"no connected MCP client",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters the list via the search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<AppsScreen {...buildProps()} />);
|
||||
|
|
|
|||
|
|
@ -29,13 +29,21 @@ import { hasInputFields, resolveDisplayLabel } from "../../../utils/toolUtils";
|
|||
export interface AppsScreenProps {
|
||||
tools: Tool[];
|
||||
listChanged: boolean;
|
||||
sandboxPath: string;
|
||||
/**
|
||||
* URL of the inspector's sandbox proxy page (the trusted outer iframe). When
|
||||
* undefined, MCP Apps cannot run (legacy backend, or a build without the
|
||||
* sandbox controller) and the screen renders an unavailable state instead of
|
||||
* a silently blank iframe.
|
||||
*/
|
||||
sandboxPath?: string;
|
||||
bridgeFactory: BridgeFactory;
|
||||
rendererRef: Ref<AppRendererHandle>;
|
||||
onRefreshList: () => void;
|
||||
onSelectApp: (name: string) => void;
|
||||
onOpenApp: (name: string, args: Record<string, unknown>) => void;
|
||||
onCloseApp: () => void;
|
||||
/** Surfaces bridge/runtime failures from the renderer (e.g. no client). */
|
||||
onError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
const ScreenLayout = Flex.withProps({
|
||||
|
|
@ -122,6 +130,7 @@ export function AppsScreen({
|
|||
onSelectApp,
|
||||
onOpenApp,
|
||||
onCloseApp,
|
||||
onError,
|
||||
}: AppsScreenProps) {
|
||||
const [selectedAppName, setSelectedAppName] = useState<string | undefined>(
|
||||
undefined,
|
||||
|
|
@ -172,6 +181,21 @@ export function AppsScreen({
|
|||
setMaximized(false);
|
||||
}
|
||||
|
||||
// No sandbox proxy URL means the host can't embed the trusted outer iframe
|
||||
// the double-iframe sandbox depends on — surface that plainly instead of
|
||||
// mounting an iframe that would render blank.
|
||||
if (!sandboxPath) {
|
||||
return (
|
||||
<ScreenLayout>
|
||||
<ContentCard flex={1} h="100%">
|
||||
<EmptyState>
|
||||
MCP Apps are unavailable — the sandbox could not be reached.
|
||||
</EmptyState>
|
||||
</ContentCard>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenLayout>
|
||||
{!maximized && (
|
||||
|
|
@ -247,6 +271,7 @@ export function AppsScreen({
|
|||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
bridgeFactory={bridgeFactory}
|
||||
onError={onError}
|
||||
ref={rendererRef}
|
||||
/>
|
||||
</RendererFrame>
|
||||
|
|
|
|||
Loading…
Reference in a new issue