Developing widgets in isolation
The standard loop is the mcp-use CLI
Day-to-day widget development runs through mcp-use dev and its built-in inspector — real server, HMR on the widget sources; see getting started. This guide covers the optional fixture harness for the cases the real server won't easily produce: edge states (empty lists, errors, huge data), the theme/brand matrix, and work without any server at all.
Hand-built widgets are where the design quality lives, so they deserve a tight feedback loop. WidgetFixtureHost is a "Storybook for MCP widgets": it renders a single widget with fixture data and a mocked host, so you can build and polish it without booting a real MCP host or backend.
The runnable version is examples/widget-playground/:
pnpm --filter @miragon/mcp-toolkit-examples run dev:widget-playgroundWhat the harness mocks
WidgetFixtureHost (from @miragon/mcp-toolkit-ui/app) stands in for the host so a widget renders exactly as it would in production, minus the infrastructure:
callToolis served from an in-memoryFixtureCallToolRegistry, wired into the simulatedHostBridgeand the toolkit'suseToolQuery— so a widget that self-fetches resolves against your fixtures.- Host actions (
openExternal,sendFollowUpMessage, …) are logged toonHostActioninstead of dispatched. - The
<ModelContext>string the widget reports is captured and surfaced throughonModelContext, so you can see what the model would see. - A
HostBridge(acreateStandaloneHostBridgeover the same registry) is provided, so widgets written againstuseHostBridgeare simulated from the same single source — see host portability. - The host's theme and language.
globals.theme("light"or"dark") is whatuseHostBridge().themereports;globals.locale(a BCP 47 tag such as"de-DE") sets the language and number format of the toolkit's own text (list footers, empty and error states, toasts), exactly asMcpAppViewdoes with the host's locale. Your widget's own text followsuseLocale(), so wrap the harness in aLocaleProviderwith the same locale to preview it in German. - What a view gives every widget: a toast stack (
useToast()shows real toasts) and the CI icon stroke (2.5) for Lucide icons.
It is intentionally minimal — a harness, not a host. The Apps-SDK postMessage bridge and host features beyond the bridge verbs are out of scope.
Mounting a widget
import { WidgetFixtureHost } from "@miragon/mcp-toolkit-ui/app"
import { OrderCard } from "./OrderCard.js"
;<WidgetFixtureHost
widget={OrderCard}
// Shaped however the widget reads it. Widgets reading keys["ns:thing"]
// pass the keys map; raw ({ data }) widgets get the same object as `data`.
data={{ "orders:order": { id: "ORD-4471", status: "shipped" } }}
// For widgets that self-fetch via useToolQuery, register the tool fixtures:
tools={{ "orders_get-order": (args) => ({ id: args.id, status: "shipped" }) }}
// For adaptDataWidget components, also pass the step dataType:
// dataType="orders:order"
onHostAction={(a) => console.log(a)}
onModelContext={(text) => console.log(text)}
/>A tools entry is either a static value returned as the tool result, or a handler (args) => result that reacts to the widget's input. An unknown tool rejects with a descriptive error that flows into the widget's own error state (TanStack Query error, mcp-use isError) rather than crashing the harness — so you exercise the failure path too.
The playground workflow
examples/widget-playground/ wraps the harness in a small Vite app:
- Sidebar — pick a widget from
stories.ts. - Fixture data — a live JSON editor for the
data/keys. Typing re-renders the preview; invalid JSON is reported and the last valid data stays on screen. - Live preview — the widget rendered through
WidgetFixtureHost, with a brand switcher, a Dark switch (the host's colour scheme) and an English/German switch (the host's locale). - Model context — the serialized
<ModelContext>the widget reports. - Host activity — the tool calls and host actions it triggered.
Add a widget by appending a Story to stories.ts:
{
id: "order-card",
label: "OrderCard",
description: "An order's status and line items.",
widget: OrderCard,
data: { "orders:order": { /* … */ } },
tools: { "orders_get-order": (args) => ({ id: args.id }) },
}Keep widgets fixture-friendly
The harness is most useful when widgets are pure functions of their input. A widget that reads keys["ns:thing"] or takes a single data prop (via adaptDataWidget) is trivial to fix in fixtures; one that reaches into ambient host state is not. This is the same property that makes the data-widget layer testable, so the fixture workflow and the layered-adoption story reinforce each other.
See also
- Host portability — the bridge the harness simulates.
- Widgets — the
WidgetPropsshape the harness builds. - Source:
packages/ui/src/app/widget-fixture.tsx.