Skip to content

Commit b494d52

Browse files
feat(mcp): widget preview fixtures + tenant branding for widgets
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
1 parent 90c0356 commit b494d52

34 files changed

Lines changed: 2697 additions & 87 deletions

File tree

mcp-server/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ MCP_SERVER_URL=http://localhost:3000
3838
# Bind address — mcp-use defaults to "localhost", which is loopback-only.
3939
# In Docker/production set 0.0.0.0 (the Dockerfile already does).
4040
# HOST=0.0.0.0
41+
42+
# Dev only — register the lms_demo_* widget preview tools and drop OAuth so the
43+
# inspector can render every widget from src/demo-data.ts with no DB and no
44+
# login. Ignored when NODE_ENV=production. See docs/WIDGET_DEMO_DATA.md.
45+
MCP_DEMO_WIDGETS=0

mcp-server/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ Thumbs.db
4242
*.swo
4343
*~
4444

45+
46+
# Widget preview renders (regenerate with scripts/shoot-demo-widgets.sh)
47+
demo-shots/
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Widget demo data & preview mode
2+
3+
Every MCP App widget in `resources/` has hand-written fixtures so you can look at
4+
it — including its empty and edge states — with **no database, no seed data and
5+
no OAuth login**.
6+
7+
## Run it
8+
9+
```bash
10+
cd mcp-server
11+
MCP_DEMO_WIDGETS=1 npm run dev
12+
# → Inspector: http://localhost:3000/inspector?autoConnect=http%3A%2F%2Flocalhost%3A3000%2Fmcp
13+
```
14+
15+
Pick any `lms_demo_*` tool, optionally set `variant`, hit **Execute**. The widget
16+
renders in the response pane (the ⤢ button gives it the full pane).
17+
18+
Headless PNGs of every fixture:
19+
20+
```bash
21+
./scripts/shoot-demo-widgets.sh dark # → demo-shots/dark/<widget>--<variant>.png
22+
./scripts/shoot-demo-widgets.sh light
23+
```
24+
25+
## How the mode works
26+
27+
| Piece | File |
28+
| --- | --- |
29+
| The fixtures | `src/demo-data.ts` (`WIDGET_DEMOS`) |
30+
| One `lms_demo_<widget>` tool per entry | `src/tools/demo.ts` |
31+
| The gate: `MCP_DEMO_WIDGETS=1` **and** `NODE_ENV !== production` | `src/env.ts``demoWidgetsEnabled()` |
32+
33+
Three deliberate consequences of the flag, all dev-only:
34+
35+
1. **OAuth is not installed** (`index.ts`). Bearer auth on `/mcp` would 401 the
36+
inspector before any handler ran, and the whole point is rendering widgets
37+
without Supabase.
38+
2. **Demo tools register before `installToolGuards()`**, so the role gate does
39+
not reject them (an inspector session with no login has no tenant role) and
40+
they never write to `mcp_audit_log`.
41+
3. **`tools/list` shows only the 18 demo tools.** With no auth there is no role,
42+
so the existing policy filter hides everything else. Real tools still refuse
43+
to run — `LmsSession.fromContext` throws "Authentication required."
44+
45+
Widget→host calls (`useCallTool`) still fail in this mode: `lms_grade_review`,
46+
`lms_complete_lesson` etc. are not reachable without a session. That is expected
47+
— you are looking at rendering, not round-trips.
48+
49+
## School branding
50+
51+
Widgets pick up the tenant's `primary_color` the way the app does, so a school's
52+
widgets match its dashboard.
53+
54+
- **Server**`src/branding.ts` reads `tenants.name / logo_url / primary_color /
55+
secondary_color` on the caller's RLS-scoped client, cached 5 min per tenant.
56+
`installToolGuards` (`src/register.ts`) attaches it to the `_meta` of any
57+
result that has `structuredContent`, so every widget-rendering tool is themed
58+
without touching its handler.
59+
- **Widget**`resources/shared/branding.tsx` reads it from
60+
`useWidget().metadata` and emits a `:root` ramp (`--brand-50 … --brand-950`)
61+
derived from the one colour with `color-mix()`. Every widget renders `<Brand />`
62+
in both its pending and loaded branches.
63+
- **Styling** — accents are `bg-[var(--brand-600)]`, `text-[var(--brand-400)]`, …
64+
With no tenant colour the component emits Tailwind's violet ramp, so an
65+
unbranded school renders exactly as before.
66+
67+
Two constraints worth knowing before you touch this:
68+
69+
- **`resources/styles.css` is not part of the build.** `mcp-use dev/build`
70+
generates its own Tailwind entry per widget under `.mcp-use/<widget>/styles.css`
71+
and imports that; there is no CLI hook to add your own. `@theme`, `@utility`
72+
and plain rules put in `resources/styles.css` silently do nothing — which is
73+
why the ramp is CSS custom properties + arbitrary-value utilities rather than
74+
a `brand-600` theme colour.
75+
- **Status colours stay semantic.** Green/amber/red mastery bars, `at risk` and
76+
`published` pills are meaning, not brand — they are deliberately not themed.
77+
78+
Preview any widget under a fake school with the `brand` argument on the demo
79+
tools: `none` (default), `ocean`, `sunset`, `forest`.
80+
81+
## Editing fixtures
82+
83+
Fixtures must satisfy each widget's own `propsSchema`. When you change a widget
84+
schema, update the fixture in the same commit: `src/demo-data.ts` is the only
85+
committed example of each widget's payload, and the demo tool will throw on a
86+
mismatch the moment you execute it.
87+
88+
Keep the awkward values. Null descriptions, comma-string `tags`, `0%`, ungraded
89+
rows and unnamed students are in there on purpose — they are what the widgets
90+
actually get, and they are what surfaces layout bugs.

mcp-server/index.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { MCPServer, oauthSupabaseProvider } from "mcp-use/server";
2-
import { getSupabaseUrl, shouldVerifyJwt } from "./src/env.js";
2+
import { getSupabaseUrl, shouldVerifyJwt, demoWidgetsEnabled } from "./src/env.js";
33
import { installToolGuards } from "./src/register.js";
44
import { installToolPolicy } from "./src/tool-policy.js";
55
import { installAuthRoutes } from "./src/auth-routes.js";
@@ -18,6 +18,7 @@ import { registerFlashcardTools } from "./src/tools/flashcards.js";
1818
import { registerStudyPlanTools } from "./src/tools/study-plan.js";
1919
import { registerAskTeacherTools } from "./src/tools/ask-teacher.js";
2020
import { registerLandingPageTools } from "./src/tools/landing-pages.js";
21+
import { registerDemoTools } from "./src/tools/demo.js";
2122
import { registerResources } from "./src/resources.js";
2223
import { registerPrompts } from "./src/prompts.js";
2324

@@ -49,12 +50,31 @@ const server = new MCPServer({
4950
],
5051
// Supabase OAuth 2.1: clients authenticate against Supabase; we verify the
5152
// resulting JWTs. Tenant/role claims come from the LMS custom_access_token_hook.
52-
oauth: oauthSupabaseProvider({
53-
supabaseUrl: getSupabaseUrl(),
54-
verifyJwt: shouldVerifyJwt(),
55-
}),
53+
//
54+
// Widget-preview mode (MCP_DEMO_WIDGETS=1, dev only) drops OAuth entirely:
55+
// bearer auth on /mcp would otherwise 401 the inspector before any handler
56+
// runs, and the whole point of the mode is rendering widgets with no Supabase
57+
// and no login. With no auth there is no tenant role, so `tools/list` exposes
58+
// only the `lms_demo_*` fixtures — every real tool still refuses to run
59+
// ("Authentication required" from LmsSession) because it has no session.
60+
...(demoWidgetsEnabled()
61+
? {}
62+
: {
63+
oauth: oauthSupabaseProvider({
64+
supabaseUrl: getSupabaseUrl(),
65+
verifyJwt: shouldVerifyJwt(),
66+
}),
67+
}),
5668
});
5769

70+
// Dev-only widget previews (MCP_DEMO_WIDGETS=1, never in production). Must be
71+
// registered BEFORE installToolGuards: an inspector session with no LMS login
72+
// has no tenant role, and the guards would reject every call. These handlers
73+
// serve static fixtures and never touch a session or Supabase.
74+
if (demoWidgetsEnabled()) {
75+
registerDemoTools(server);
76+
}
77+
5878
// Per-tool guards: role-based call gating + audit logging to mcp_audit_log.
5979
// Must run BEFORE registering tools — it monkey-patches server.tool to wrap
6080
// every handler (the tool name isn't available in mcp-use 1.32.0 middleware).

mcp-server/resources/artifact-sandbox/widget.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useCallTool,
77
type WidgetMetadata,
88
} from "mcp-use/react";
9+
import { Brand } from "../shared/branding";
910
import { z } from "zod";
1011

1112
// ── Schema ──────────────────────────────────────────────────────────────────
@@ -97,9 +98,10 @@ export default function ArtifactSandbox() {
9798
if (isPending) {
9899
return (
99100
<McpUseProvider autoSize>
101+
<Brand />
100102
<div className={dark ? "dark" : ""}>
101103
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
102-
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-violet-600 dark:border-zinc-800 dark:border-t-violet-400" />
104+
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-[var(--brand-600)] dark:border-zinc-800 dark:border-t-[var(--brand-400)]" />
103105
<p className="m-0 text-sm">Rendering artifact…</p>
104106
</div>
105107
</div>
@@ -120,7 +122,7 @@ export default function ArtifactSandbox() {
120122
onClick={() => setTab(id)}
121123
className={`cursor-pointer rounded-lg border-none px-3.5 py-1.5 text-[13px] transition-all duration-150 ${
122124
tab === id
123-
? "bg-violet-50 font-semibold text-violet-600 dark:bg-violet-950 dark:text-violet-400"
125+
? "bg-[var(--brand-50)] font-semibold text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]"
124126
: "bg-transparent font-medium text-zinc-500 dark:text-zinc-400"
125127
}`}
126128
>
@@ -130,6 +132,7 @@ export default function ArtifactSandbox() {
130132

131133
return (
132134
<McpUseProvider autoSize>
135+
<Brand />
133136
<div className={dark ? "dark" : ""}>
134137
<div className="bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
135138
{/* Header */}
@@ -201,7 +204,7 @@ export default function ArtifactSandbox() {
201204
disabled={saving || !dirty}
202205
className={`rounded-lg border-none px-[18px] py-2 text-[13px] font-semibold transition-all duration-150 ${
203206
dirty
204-
? "cursor-pointer bg-violet-600 text-white dark:bg-violet-400 dark:text-zinc-950"
207+
? "cursor-pointer bg-[var(--brand-600)] text-white dark:bg-[var(--brand-400)] dark:text-zinc-950"
205208
: "cursor-not-allowed bg-zinc-200 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500"
206209
} ${saving ? "cursor-not-allowed opacity-70" : ""}`}
207210
>

mcp-server/resources/course-catalog/widget.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useCallTool,
77
type WidgetMetadata,
88
} from "mcp-use/react";
9+
import { Brand } from "../shared/branding";
910
import { z } from "zod";
1011

1112
// ── Schema ──────────────────────────────────────────────────────────────────
@@ -75,9 +76,10 @@ export default function CourseCatalog() {
7576
if (isPending) {
7677
return (
7778
<McpUseProvider autoSize>
79+
<Brand />
7880
<div className={dark ? "dark" : ""}>
7981
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
80-
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-violet-600 dark:border-zinc-800 dark:border-t-violet-400" />
82+
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-[var(--brand-600)] dark:border-zinc-800 dark:border-t-[var(--brand-400)]" />
8183
<p className="m-0 text-sm">Browsing catalog…</p>
8284
</div>
8385
</div>
@@ -114,6 +116,7 @@ export default function CourseCatalog() {
114116

115117
return (
116118
<McpUseProvider autoSize>
119+
<Brand />
117120
<div className={dark ? "dark" : ""}>
118121
<div className="mx-auto max-w-[820px] bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
119122
<div className="mb-4.5 flex flex-wrap items-baseline justify-between gap-2">
@@ -201,12 +204,12 @@ export default function CourseCatalog() {
201204
<button
202205
onClick={() => handleEnroll(course.id, course.title)}
203206
disabled={pendingIds.has(course.id)}
204-
className="cursor-pointer rounded-full border-none bg-violet-600 px-2.5 py-[3px] text-[11px] font-bold text-white disabled:cursor-default disabled:opacity-60 dark:bg-violet-400"
207+
className="cursor-pointer rounded-full border-none bg-[var(--brand-600)] px-2.5 py-[3px] text-[11px] font-bold text-white disabled:cursor-default disabled:opacity-60 dark:bg-[var(--brand-400)]"
205208
>
206209
{pendingIds.has(course.id) ? "Enrolling…" : "Enroll"}
207210
</button>
208211
) : course.has_access ? (
209-
<span className="rounded-full bg-violet-100 px-[9px] py-[3px] text-[11px] font-bold text-violet-600 dark:bg-violet-950 dark:text-violet-400">
212+
<span className="rounded-full bg-[var(--brand-100)] px-[9px] py-[3px] text-[11px] font-bold text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]">
210213
Access
211214
</span>
212215
) : (

mcp-server/resources/course-dashboard/widget.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
useWidgetTheme,
66
type WidgetMetadata,
77
} from "mcp-use/react";
8+
import { Brand } from "../shared/branding";
89
import { z } from "zod";
910

1011
// ── Schema ──────────────────────────────────────────────────────────────────
@@ -60,7 +61,7 @@ function statusColor(status: string): string {
6061
case "draft":
6162
return "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400";
6263
case "archived":
63-
return "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400";
64+
return "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]";
6465
default:
6566
return "bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400";
6667
}
@@ -78,9 +79,10 @@ export default function CourseDashboard() {
7879
if (isPending) {
7980
return (
8081
<McpUseProvider autoSize>
82+
<Brand />
8183
<div className={dark ? "dark" : ""}>
8284
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
83-
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-violet-600 dark:border-zinc-800 dark:border-t-violet-400" />
85+
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-[var(--brand-600)] dark:border-zinc-800 dark:border-t-[var(--brand-400)]" />
8486
<p className="m-0 text-sm">Loading courses…</p>
8587
</div>
8688
</div>
@@ -99,6 +101,7 @@ export default function CourseDashboard() {
99101

100102
return (
101103
<McpUseProvider autoSize>
104+
<Brand />
102105
<div className={dark ? "dark" : ""}>
103106
<div className="min-h-0 bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
104107
{/* Header */}
@@ -122,7 +125,7 @@ export default function CourseDashboard() {
122125
onClick={() => setActiveFilter(s)}
123126
className={`cursor-pointer rounded-full border px-3 py-[5px] text-xs transition-all duration-150 ${
124127
active
125-
? "border-violet-600 bg-violet-50 font-semibold text-violet-600 dark:border-violet-400 dark:bg-violet-950 dark:text-violet-400"
128+
? "border-[var(--brand-600)] bg-[var(--brand-50)] font-semibold text-[var(--brand-600)] dark:border-[var(--brand-400)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]"
126129
: "border-zinc-200 bg-transparent font-normal text-zinc-500 dark:border-zinc-800 dark:text-zinc-400"
127130
}`}
128131
>

mcp-server/resources/course-detail/widget.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useCallTool,
77
type WidgetMetadata,
88
} from "mcp-use/react";
9+
import { Brand } from "../shared/branding";
910
import { z } from "zod";
1011

1112
// ── Schema ──────────────────────────────────────────────────────────────────
@@ -63,7 +64,7 @@ function statusPill(status: string): string {
6364
case "draft":
6465
return "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400";
6566
case "archived":
66-
return "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400";
67+
return "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]";
6768
default:
6869
return "bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400";
6970
}
@@ -115,9 +116,10 @@ export default function CourseDetail() {
115116
if (isPending) {
116117
return (
117118
<McpUseProvider autoSize>
119+
<Brand />
118120
<div className={dark ? "dark" : ""}>
119121
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
120-
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-violet-600 dark:border-zinc-800 dark:border-t-violet-400" />
122+
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-[var(--brand-600)] dark:border-zinc-800 dark:border-t-[var(--brand-400)]" />
121123
<p className="m-0 text-sm">Loading course…</p>
122124
</div>
123125
</div>
@@ -135,6 +137,7 @@ export default function CourseDetail() {
135137

136138
return (
137139
<McpUseProvider autoSize>
140+
<Brand />
138141
<div className={dark ? "dark" : ""}>
139142
<div className="bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
140143
{/* Course header */}
@@ -174,9 +177,9 @@ export default function CourseDetail() {
174177
<button
175178
onClick={handleLoadStats}
176179
disabled={statsLoading}
177-
className={`cursor-pointer rounded-lg border border-violet-600 px-4 py-[7px] text-[13px] font-medium text-violet-600 transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-70 dark:border-violet-400 dark:text-violet-400 ${
180+
className={`cursor-pointer rounded-lg border border-[var(--brand-600)] px-4 py-[7px] text-[13px] font-medium text-[var(--brand-600)] transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-70 dark:border-[var(--brand-400)] dark:text-[var(--brand-400)] ${
178181
statsVisible
179-
? "bg-violet-50 dark:bg-violet-950"
182+
? "bg-[var(--brand-50)] dark:bg-[var(--brand-950)]"
180183
: "bg-transparent"
181184
}`}
182185
>
@@ -241,7 +244,7 @@ export default function CourseDetail() {
241244
key={lesson.id}
242245
className="flex items-center gap-2.5 rounded-lg bg-zinc-100 px-2.5 py-2 dark:bg-zinc-800"
243246
>
244-
<span className="flex h-6 min-w-6 shrink-0 items-center justify-center rounded-full bg-violet-50 text-[11px] font-bold text-violet-600 dark:bg-violet-950 dark:text-violet-400">
247+
<span className="flex h-6 min-w-6 shrink-0 items-center justify-center rounded-full bg-[var(--brand-50)] text-[11px] font-bold text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]">
245248
{lesson.sequence}
246249
</span>
247250
<span className="flex-1 text-[13px] font-medium text-zinc-900 dark:text-zinc-100">

0 commit comments

Comments
 (0)