Every MCP App widget in resources/ has hand-written fixtures so you can look at
it — including its empty and edge states — with no database, no seed data and
no OAuth login.
cd mcp-server
MCP_DEMO_WIDGETS=1 npm run dev
# → Inspector: http://localhost:3000/inspector?autoConnect=http%3A%2F%2Flocalhost%3A3000%2FmcpPick any lms_demo_* tool, optionally set variant, hit Execute. The widget
renders in the response pane (the ⤢ button gives it the full pane).
Headless PNGs of every fixture:
./scripts/shoot-demo-widgets.sh dark # → demo-shots/dark/<widget>--<variant>.png
./scripts/shoot-demo-widgets.sh light| Piece | File |
|---|---|
| The fixtures | src/demo-data.ts (WIDGET_DEMOS) |
One lms_demo_<widget> tool per entry |
src/tools/demo.ts |
The gate: MCP_DEMO_WIDGETS=1 and NODE_ENV !== production |
src/env.ts → demoWidgetsEnabled() |
Three deliberate consequences of the flag, all dev-only:
- OAuth is not installed (
index.ts). Bearer auth on/mcpwould 401 the inspector before any handler ran, and the whole point is rendering widgets without Supabase. - Demo tools register before
installToolGuards(), so the role gate does not reject them (an inspector session with no login has no tenant role) and they never write tomcp_audit_log. tools/listshows only the 18 demo tools. With no auth there is no role, so the existing policy filter hides everything else. Real tools still refuse to run —LmsSession.fromContextthrows "Authentication required."
Widget→host calls (useCallTool) still fail in this mode: lms_grade_review,
lms_complete_lesson etc. are not reachable without a session. That is expected
— you are looking at rendering, not round-trips.
Widgets pick up the tenant's primary_color the way the app does, so a school's
widgets match its dashboard.
- Server —
src/branding.tsreadstenants.name / logo_url / primary_color / secondary_coloron the caller's RLS-scoped client, cached 5 min per tenant.installToolGuards(src/register.ts) attaches it to the_metaof any result that hasstructuredContent, so every widget-rendering tool is themed without touching its handler. - Widget —
resources/shared/branding.tsxreads it fromuseWidget().metadataand emits a:rootramp (--brand-50 … --brand-950) derived from the one colour withcolor-mix(). Every widget renders<Brand />in both its pending and loaded branches. - Styling — accents are
bg-[var(--brand-600)],text-[var(--brand-400)], … With no tenant colour the component emits Tailwind's violet ramp, so an unbranded school renders exactly as before.
Two constraints worth knowing before you touch this:
resources/styles.cssis not part of the build.mcp-use dev/buildgenerates its own Tailwind entry per widget under.mcp-use/<widget>/styles.cssand imports that; there is no CLI hook to add your own.@theme,@utilityand plain rules put inresources/styles.csssilently do nothing — which is why the ramp is CSS custom properties + arbitrary-value utilities rather than abrand-600theme colour.- Status colours stay semantic. Green/amber/red mastery bars,
at riskandpublishedpills are meaning, not brand — they are deliberately not themed.
Preview any widget under a fake school with the brand argument on the demo
tools: none (default), ocean, sunset, forest.
Until #570 this argument did nothing.
src/tools/demo.tscomputed the brand and named it in the output text but never attached it to the result's_meta, so every "branded" preview rendered the platform violet. It now passes it throughwidget({ metadata }), the same sideband the real server uses.
Widget chrome is en/es. The language comes from the host —
useWidget().locale, a BCP 47 tag (SEP-1865 HostContext.locale) — and
resources/shared/i18n.tsx turns it into a string table plus Intl
formatters. Only strings the widget owns are translated; course titles, lesson
titles and tags come out of the database in the school's own language and are
rendered verbatim.
The preview harness supplies no host locale, so everything would otherwise
render at the "en" default and the Spanish half would never be reviewable.
Pass lang=es (or en) to any demo tool to pin it:
npx mcp-use client screenshot --mcp http://localhost:3000/mcp \
--tool lms_demo_school_overview variant=default lang=es \
--width 1100 --height 800 --theme dark --output es.pngThat rides the same _meta channel as branding (lms/locale, see
src/locale.ts). Nothing in production sets it — the host stays the source of
truth. The fixtures are deliberately Spanish, so lang=es is the combination
that matches a real school, and lang=en next to Spanish content is the
mismatch #570 was filed about.
Fixtures must satisfy each widget's own propsSchema. When you change a widget
schema, update the fixture in the same commit: src/demo-data.ts is the only
committed example of each widget's payload, and the demo tool will throw on a
mismatch the moment you execute it.
Keep the awkward values. Null descriptions, comma-string tags, 0%, ungraded
rows and unnamed students are in there on purpose — they are what the widgets
actually get, and they are what surfaces layout bugs.