feat(mcp): widget preview fixtures + tenant branding for widgets - #572
Merged
Conversation
Two things the MCP widgets were missing: a way to look at them, and the school's own colours. **Preview mode.** Widget props came only from live Supabase reads, so seeing a widget needed a seeded DB, an OAuth login, and a row that happened to hit the branch you cared about — and edge states (empty lists, locked lessons, null scores, unpublished pages) were near-impossible to produce on demand. `MCP_DEMO_WIDGETS=1` registers one `lms_demo_<widget>` tool per widget serving hand-written fixtures for all 18, 44 variants covering the awkward cases. The flag is hard-gated on a non-production NODE_ENV; with it unset nothing registers, OAuth is installed as before and unauthenticated /mcp still 401s. Fixtures are also now the only committed example of each widget's payload, so a propsSchema change has to update them. **Tenant branding.** Widgets hardcoded violet while the app themes off `tenants.primary_color` (components/tenant/tenant-css-vars-server.tsx). The server now attaches branding to the tool result's `_meta` centrally in installToolGuards — no handler changes — and the widget derives a `--brand-50..950` ramp from that one colour with color-mix(). With no tenant colour it emits Tailwind's violet ramp verbatim, so unbranded schools are pixel-identical to before. The ramp is CSS custom properties plus arbitrary-value utilities (`bg-[var(--brand-600)]`) rather than a Tailwind theme colour because mcp-use generates its own per-widget Tailwind entry under .mcp-use/ and offers no hook to add one: resources/styles.css has never been part of the widget build. Noted in the file. Status colours (red/amber/green mastery bars, at-risk and published pills) are deliberately left unthemed — they carry meaning, not brand. Findings from the review pass this enabled are tracked in #571. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017sLtN12VfnLziCpst3Pfo2
`numbersIn` located step markers by grepping the rendered markup for `border-violet-600`. Widgets now paint accents with the tenant brand ramp, so that class no longer exists and the matcher returned [] — the assertion failed even though step numbering (the #561 regression this guards) is unchanged. Anchor on `border-[var(--brand-600)]` instead, which follows the accent rather than a fixed hue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017sLtN12VfnLziCpst3Pfo2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two things for the MCP App widgets:
MCP_DEMO_WIDGETS=1registers onelms_demo_<widget>tool per widget, serving hand-written fixtures — all 18 widgets, 44 variants covering empty lists, null names, ungraded rows, locked lessons, unpublished pages. No database, no seed, no OAuth.tenants.primary_color, the same source the app uses, instead of hardcoding violet.Enables #571 (the findings it produced are filed as #566–#570). Does not close it.
Why
Widget props came only from live Supabase reads, so seeing a widget meant a seeded DB, an OAuth login, and finding a row that happened to hit the branch you were working on. Edge states were near-impossible to produce on demand — which is why several of the bugs in #566–#570 had been sitting in the widgets unnoticed. They all showed up within minutes of rendering against deliberately awkward fixtures.
Branding was the obvious gap once the widgets were visible side by side with the app: the app injects the tenant's colours as CSS custom properties (
components/tenant/tenant-css-vars-server.tsx), and widgets — rendering in a host iframe that never sees that markup — stayed platform violet.How it works
Fixtures —
mcp-server/src/demo-data.ts(data only, never imported by a production tool) andsrc/tools/demo.ts. The flag is gated onMCP_DEMO_WIDGETS=1andNODE_ENV !== production(src/env.ts,demoWidgetsEnabled()).Three deliberate dev-only consequences of the flag, all documented in
mcp-server/docs/WIDGET_DEMO_DATA.md:/mcpwould 401 the inspector before any handler ran.installToolGuards(), so the role gate doesn't reject them (a session with no login has no tenant role) and they never write tomcp_audit_log.tools/listexposes only the 18 demo tools; every real tool still refuses to run (LmsSession.fromContextthrows).Branding —
src/branding.tsreadsname / logo_url / primary_color / secondary_coloron the caller's RLS-scoped client, cached 5 min per tenant (misses cached too).installToolGuardsattaches it to_metaon any result carryingstructuredContent, so all 18 widget tools are themed without touching a single handler.resources/shared/branding.tsxreadsuseWidget().metadataand emits a--brand-50..950ramp derived from the one colour withcolor-mix()in oklab. Tenant-controlled colour is allow-listed before it reaches a<style>tag.With no tenant colour it emits Tailwind's violet ramp verbatim, so unbranded schools are pixel-identical to before.
Two things a reviewer should know
mcp-server/resources/styles.csshas never been part of the widget build.mcp-use dev/buildgenerates its own Tailwind entry per widget at.mcp-use/<widget>/styles.cssand imports that; the CLI exposes no hook to add your own. Everything in that file — thebody/h1/abase rules, the carousel classes — has been dead. That is why the ramp uses arbitrary-value utilities (bg-[var(--brand-600)]) rather than abrand-600theme colour. Left in place with a warning comment rather than deleted in this PR; happy to remove it if you'd rather.at risk/publishedpills encode meaning, not brand. Under a forest-green brand the buttons theme and the mastery bars stay red/amber/green — that's intended, not a miss (QA step 3).How to QA
No
db:resetneeded — that's the point of the change.lms_demo_*tools and nothing else.variant=empty/locked/broken-mdxfor the edge states.brand=ocean(orsunset/forest) and re-run — accents follow the colour, mastery bars stay red/amber/green.brand=noneand confirm it's the original violet.demo-shots/<theme>/(gitignored).Confirm the flag is genuinely off by default — the part worth checking hardest:
Expect: no
[demo] Registered …line,[OAuth] Bearer authentication enabledpresent, and→
401.Screenshots
Not embedded — the renders are reproducible in one command (step 5 above) and
demo-shots/is gitignored. Say the word and I'll attach the before/after branding pair.The visual result: identical violet unbranded → ocean
#0369a1/ sunset#e11d48/ forest#15803dapplied to accents across light and dark, with status colours unchanged.Live path verified
The real path — genuine Supabase session → RLS read of
tenants→ branding in_meta— has now been exercised against local Supabase.Seeded
code-academywith a deliberately un-violet colour so the result couldn't be ambiguous (primary_color = #0f766e,secondary_color = #f59e0b; it ships as#7c3aed, which is indistinguishable from the platform default).creator@codeacademy.com),lms_list_courses_meta['lms/branding']present with the seeded teal; the widget's filter chip renders teal,publishedpills stay greenalice@student.com),lms_my_learningtenantsRLS policy admits any tenant member, so student widgets are themed tooprimary_color/secondary_color/logo_urlall NULL_metais[], branding absent, widget falls back to the platform violetRun with OAuth on and
MCP_DEMO_WIDGETSunset, so this is the production code path, not the demo one.Checklist
npm run typecheckandnpm run test:unitpass — root suite 594/594 (48 files),mcp-server39/39 (4 new insrc/branding.test.ts)npm run buildpasses —mcp-serverproduction build green, 18 widgets built; the brand utilities compile into the built CSS (10 distinctvar(--brand-*)) and the ramp ships in 42 built JS bundles. Root Next build not run: no files outsidemcp-server/andtests/unit/changedtenant_id— the one new query (branding.ts) filterstenants.idon the caller's RLS client<Brand />renders in every widget's pending and loaded branch; branding failures degrade to the default palettemessages/en.json/es.json— N/A here, and tracked as a real gap: widgets have no i18n at all (MCP widget UX polish: i18n, colour semantics, CTAs, unused payload data, card alignment #570)🤖 Generated with Claude Code
https://claude.ai/code/session_017sLtN12VfnLziCpst3Pfo2