inspector/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx
Cliff Hall 5fb2b503f4
feat(apps): render running MCP Apps end-to-end (#1370) (#1403)
* 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>
2026-06-01 18:01:44 -04:00

523 lines
16 KiB
TypeScript

import { createRef, StrictMode } from "react";
import { act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
AppRenderer,
type AppRendererHandle,
type BridgeFactory,
} from "./AppRenderer";
const tool: Tool = {
name: "cohort_app",
title: "Cohort App",
inputSchema: { type: "object" },
};
interface MockBridge {
sendToolInput: ReturnType<typeof vi.fn>;
sendToolResult: ReturnType<typeof vi.fn>;
sendToolCancelled: ReturnType<typeof vi.fn>;
teardownResource: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
/** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */
emit: (event: string, payload?: unknown) => void;
}
function createMockBridge(): MockBridge {
const listeners: Record<string, ((payload: unknown) => void)[]> = {};
return {
sendToolInput: vi.fn().mockResolvedValue(undefined),
sendToolResult: vi.fn().mockResolvedValue(undefined),
sendToolCancelled: vi.fn().mockResolvedValue(undefined),
teardownResource: vi.fn().mockResolvedValue({}),
close: vi.fn().mockResolvedValue(undefined),
addEventListener: vi.fn((event: string, handler: (p: unknown) => void) => {
(listeners[event] ??= []).push(handler);
}),
removeEventListener: vi.fn(
(event: string, handler: (p: unknown) => void) => {
listeners[event] = (listeners[event] ?? []).filter(
(h) => h !== handler,
);
},
),
emit: (event: string, payload?: unknown) => {
(listeners[event] ?? []).forEach((h) => h(payload));
},
};
}
function asBridge(mock: MockBridge): AppBridge {
return mock as unknown as AppBridge;
}
// Two microtask ticks are enough to settle the bridge promise chain in the
// component when the factory is synchronous: tick 1 resolves
// `Promise.resolve(bridgeFactory(iframe))`, tick 2 runs the `.then` that
// assigns `bridgeRef.current`. Tests using a multi-hop async factory must
// flush further inline (see the post-unmount disposal cases).
async function flushAsync(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("AppRenderer", () => {
it("renders an iframe with the sandbox path and tool title", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
const iframe = screen.getByTitle("Cohort App") as HTMLIFrameElement;
expect(iframe.tagName).toBe("IFRAME");
expect(iframe.getAttribute("src")).toBe("/sandbox.html");
});
it("falls back to tool name when title is missing", () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={{ name: "no_title", inputSchema: { type: "object" } }}
bridgeFactory={() => asBridge(bridge)}
/>,
);
expect(screen.getByTitle("no_title")).toBeInTheDocument();
});
it("invokes the bridge factory with the iframe element", async () => {
const bridge = createMockBridge();
const factory = vi.fn<BridgeFactory>(() => asBridge(bridge));
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(1);
expect(factory.mock.calls[0]?.[0]).toBeInstanceOf(HTMLIFrameElement);
expect(factory.mock.calls[0]?.[1]).toBe(tool);
});
it("forwards sendToolInput through the bridge once initialized", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { city: "NYC" },
});
});
it("forwards sendToolResult through the bridge once initialized", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
const result: CallToolResult = {
content: [{ type: "text", text: "ok" }],
};
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolResult(result);
bridge.emit("initialized");
});
expect(bridge.sendToolResult).toHaveBeenCalledWith(result);
});
it("buffers tool input until the view is initialized, then flushes input before result", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
const result: CallToolResult = { content: [{ type: "text", text: "ok" }] };
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
// Pushed before `initialized` — must not reach the bridge yet.
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
await ref.current?.sendToolResult(result);
});
expect(bridge.sendToolInput).not.toHaveBeenCalled();
expect(bridge.sendToolResult).not.toHaveBeenCalled();
// Initialization releases both, input first.
await act(async () => {
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
expect(bridge.sendToolResult).toHaveBeenCalledTimes(1);
expect(bridge.sendToolInput.mock.invocationCallOrder[0]).toBeLessThan(
bridge.sendToolResult.mock.invocationCallOrder[0],
);
});
it("keeps only the latest buffered input (latest-wins)", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
await ref.current?.sendToolInput({ city: "LA" });
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { city: "LA" },
});
});
it("forwards sendToolCancelled through the bridge", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.sendToolCancelled("user-aborted");
});
expect(bridge.sendToolCancelled).toHaveBeenCalledWith({
reason: "user-aborted",
});
});
it("builds a single bridge and does not dispose it under StrictMode double-invoke", async () => {
// React StrictMode runs effects setup→cleanup→setup in dev. The bridge
// (a stateful handshake) must survive that as ONE instance — rebuilding it
// spins up a second transport that double-loads the sandbox and races the
// app's ui/initialize handshake.
const bridge = createMockBridge();
const factory = vi.fn<BridgeFactory>(() => asBridge(bridge));
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<StrictMode>
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>
</StrictMode>,
);
await flushAsync();
// One build, and the bridge is NOT torn down by the synthetic remount.
expect(factory).toHaveBeenCalledTimes(1);
expect(bridge.teardownResource).not.toHaveBeenCalled();
expect(bridge.close).not.toHaveBeenCalled();
// The reused bridge still delivers buffered input once initialized.
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
bridge.emit("initialized");
});
expect(bridge.sendToolInput).toHaveBeenCalledTimes(1);
expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { city: "NYC" },
});
});
it("teardown disposes the bridge once and is idempotent", async () => {
const bridge = createMockBridge();
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
await ref.current?.teardown();
await ref.current?.teardown();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("does not double-dispose when unmount races an in-flight teardown", async () => {
const bridge = createMockBridge();
let resolveTeardown: ((value: Record<string, unknown>) => void) | undefined;
bridge.teardownResource.mockImplementationOnce(
() =>
new Promise<Record<string, unknown>>((resolve) => {
resolveTeardown = resolve;
}),
);
const ref = createRef<AppRendererHandle>();
const { unmount } = renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
// Kick off teardown but leave its `teardownResource` await pending.
const teardownPromise = ref.current!.teardown();
// Unmount while teardown is in flight.
unmount();
// Resolve the pending teardownResource, then let `close` settle.
await act(async () => {
resolveTeardown?.({});
await teardownPromise;
for (let i = 0; i < 4; i++) {
await Promise.resolve();
}
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("send methods are silent no-ops before the bridge resolves", async () => {
const bridge = createMockBridge();
let resolveBridge: ((b: AppBridge) => void) | undefined;
const factory: BridgeFactory = () =>
new Promise<AppBridge>((resolve) => {
resolveBridge = resolve;
});
const ref = createRef<AppRendererHandle>();
renderWithMantine(
<AppRenderer
ref={ref}
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
await act(async () => {
await ref.current?.sendToolInput({ city: "NYC" });
await ref.current?.sendToolResult({ content: [] });
await ref.current?.sendToolCancelled("aborted");
await ref.current?.teardown();
});
expect(bridge.sendToolInput).not.toHaveBeenCalled();
expect(bridge.sendToolResult).not.toHaveBeenCalled();
expect(bridge.sendToolCancelled).not.toHaveBeenCalled();
expect(bridge.teardownResource).not.toHaveBeenCalled();
// Resolve to satisfy the pending factory before unmount.
resolveBridge?.(asBridge(bridge));
await flushAsync();
});
it("unmount triggers teardownResource and close", async () => {
const bridge = createMockBridge();
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("disposes a bridge that resolves after the component unmounts", async () => {
const bridge = createMockBridge();
let resolveBridge: ((b: AppBridge) => void) | undefined;
const factory: BridgeFactory = () =>
new Promise<AppBridge>((resolve) => {
resolveBridge = resolve;
});
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
unmount();
await act(async () => {
resolveBridge?.(asBridge(bridge));
// disposeBridge awaits teardownResource then close — flush enough
// microtasks to let both calls complete.
for (let i = 0; i < 8; i++) {
await Promise.resolve();
}
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("calls onError when the bridge factory throws", async () => {
const onError = vi.fn();
const factory: BridgeFactory = () => {
throw new Error("boom");
};
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
onError={onError}
/>,
);
await flushAsync();
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect((onError.mock.calls[0]?.[0] as Error).message).toBe("boom");
});
it("wraps non-Error rejections from the factory", async () => {
const onError = vi.fn();
const factory: BridgeFactory = () => Promise.reject("plain string");
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
onError={onError}
/>,
);
await flushAsync();
expect(onError).toHaveBeenCalledTimes(1);
expect((onError.mock.calls[0]?.[0] as Error).message).toBe("plain string");
});
it("does not throw when no onError is provided and the factory fails", async () => {
const factory: BridgeFactory = () => Promise.reject(new Error("ignored"));
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(screen.getByTitle("Cohort App")).toBeInTheDocument();
});
it("rebuilds the bridge when sandboxPath changes", async () => {
const first = createMockBridge();
const second = createMockBridge();
const factory = vi
.fn<BridgeFactory>()
.mockReturnValueOnce(asBridge(first))
.mockReturnValueOnce(asBridge(second));
const { rerender } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox-a.html"
tool={tool}
bridgeFactory={factory}
/>,
);
await flushAsync();
rerender(
<AppRenderer
sandboxPath="/sandbox-b.html"
tool={tool}
bridgeFactory={factory}
/>,
);
await flushAsync();
expect(factory).toHaveBeenCalledTimes(2);
expect(first.teardownResource).toHaveBeenCalledTimes(1);
expect(first.close).toHaveBeenCalledTimes(1);
});
it("swallows teardownResource errors but still closes the transport", async () => {
const bridge = createMockBridge();
bridge.teardownResource.mockRejectedValueOnce(new Error("teardown failed"));
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
it("swallows close errors during disposal", async () => {
const bridge = createMockBridge();
bridge.close.mockRejectedValueOnce(new Error("close failed"));
const { unmount } = renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
unmount();
await Promise.resolve();
await Promise.resolve();
});
expect(bridge.teardownResource).toHaveBeenCalledTimes(1);
expect(bridge.close).toHaveBeenCalledTimes(1);
});
});