Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions mcp-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ MCP_SERVER_URL=http://localhost:3000
# Bind address — mcp-use defaults to "localhost", which is loopback-only.
# In Docker/production set 0.0.0.0 (the Dockerfile already does).
# HOST=0.0.0.0

# Dev only — register the lms_demo_* widget preview tools and drop OAuth so the
# inspector can render every widget from src/demo-data.ts with no DB and no
# login. Ignored when NODE_ENV=production. See docs/WIDGET_DEMO_DATA.md.
MCP_DEMO_WIDGETS=0
3 changes: 3 additions & 0 deletions mcp-server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Thumbs.db
*.swo
*~


# Widget preview renders (regenerate with scripts/shoot-demo-widgets.sh)
demo-shots/
90 changes: 90 additions & 0 deletions mcp-server/docs/WIDGET_DEMO_DATA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Widget demo data & preview mode

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**.

## Run it

```bash
cd mcp-server
MCP_DEMO_WIDGETS=1 npm run dev
# → Inspector: http://localhost:3000/inspector?autoConnect=http%3A%2F%2Flocalhost%3A3000%2Fmcp
```

Pick 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:

```bash
./scripts/shoot-demo-widgets.sh dark # → demo-shots/dark/<widget>--<variant>.png
./scripts/shoot-demo-widgets.sh light
```

## How the mode works

| 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:

1. **OAuth is not installed** (`index.ts`). Bearer auth on `/mcp` would 401 the
inspector before any handler ran, and the whole point is rendering widgets
without Supabase.
2. **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 to `mcp_audit_log`.
3. **`tools/list` shows 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.fromContext` throws "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.

## School branding

Widgets pick up the tenant's `primary_color` the way the app does, so a school's
widgets match its dashboard.

- **Server** — `src/branding.ts` reads `tenants.name / logo_url / primary_color /
secondary_color` on the caller's RLS-scoped client, cached 5 min per tenant.
`installToolGuards` (`src/register.ts`) attaches it to the `_meta` of any
result that has `structuredContent`, so every widget-rendering tool is themed
without touching its handler.
- **Widget** — `resources/shared/branding.tsx` reads it from
`useWidget().metadata` and emits a `:root` ramp (`--brand-50 … --brand-950`)
derived from the one colour with `color-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.css` is not part of the build.** `mcp-use dev/build`
generates its own Tailwind entry per widget under `.mcp-use/<widget>/styles.css`
and imports that; there is no CLI hook to add your own. `@theme`, `@utility`
and plain rules put in `resources/styles.css` silently do nothing — which is
why the ramp is CSS custom properties + arbitrary-value utilities rather than
a `brand-600` theme colour.
- **Status colours stay semantic.** Green/amber/red mastery bars, `at risk` and
`published` pills 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`.

## Editing fixtures

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.
30 changes: 25 additions & 5 deletions mcp-server/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MCPServer, oauthSupabaseProvider } from "mcp-use/server";
import { getSupabaseUrl, shouldVerifyJwt } from "./src/env.js";
import { getSupabaseUrl, shouldVerifyJwt, demoWidgetsEnabled } from "./src/env.js";
import { installToolGuards } from "./src/register.js";
import { installToolPolicy } from "./src/tool-policy.js";
import { installAuthRoutes } from "./src/auth-routes.js";
Expand All @@ -18,6 +18,7 @@ import { registerFlashcardTools } from "./src/tools/flashcards.js";
import { registerStudyPlanTools } from "./src/tools/study-plan.js";
import { registerAskTeacherTools } from "./src/tools/ask-teacher.js";
import { registerLandingPageTools } from "./src/tools/landing-pages.js";
import { registerDemoTools } from "./src/tools/demo.js";
import { registerResources } from "./src/resources.js";
import { registerPrompts } from "./src/prompts.js";

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

// Dev-only widget previews (MCP_DEMO_WIDGETS=1, never in production). Must be
// registered BEFORE installToolGuards: an inspector session with no LMS login
// has no tenant role, and the guards would reject every call. These handlers
// serve static fixtures and never touch a session or Supabase.
if (demoWidgetsEnabled()) {
registerDemoTools(server);
}

// Per-tool guards: role-based call gating + audit logging to mcp_audit_log.
// Must run BEFORE registering tools — it monkey-patches server.tool to wrap
// every handler (the tool name isn't available in mcp-use 1.32.0 middleware).
Expand Down
9 changes: 6 additions & 3 deletions mcp-server/resources/artifact-sandbox/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useCallTool,
type WidgetMetadata,
} from "mcp-use/react";
import { Brand } from "../shared/branding";
import { z } from "zod";

// ── Schema ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -97,9 +98,10 @@ export default function ArtifactSandbox() {
if (isPending) {
return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
<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" />
<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)]" />
<p className="m-0 text-sm">Rendering artifact…</p>
</div>
</div>
Expand All @@ -120,7 +122,7 @@ export default function ArtifactSandbox() {
onClick={() => setTab(id)}
className={`cursor-pointer rounded-lg border-none px-3.5 py-1.5 text-[13px] transition-all duration-150 ${
tab === id
? "bg-violet-50 font-semibold text-violet-600 dark:bg-violet-950 dark:text-violet-400"
? "bg-[var(--brand-50)] font-semibold text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]"
: "bg-transparent font-medium text-zinc-500 dark:text-zinc-400"
}`}
>
Expand All @@ -130,6 +132,7 @@ export default function ArtifactSandbox() {

return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
{/* Header */}
Expand Down Expand Up @@ -201,7 +204,7 @@ export default function ArtifactSandbox() {
disabled={saving || !dirty}
className={`rounded-lg border-none px-[18px] py-2 text-[13px] font-semibold transition-all duration-150 ${
dirty
? "cursor-pointer bg-violet-600 text-white dark:bg-violet-400 dark:text-zinc-950"
? "cursor-pointer bg-[var(--brand-600)] text-white dark:bg-[var(--brand-400)] dark:text-zinc-950"
: "cursor-not-allowed bg-zinc-200 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500"
} ${saving ? "cursor-not-allowed opacity-70" : ""}`}
>
Expand Down
9 changes: 6 additions & 3 deletions mcp-server/resources/course-catalog/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useCallTool,
type WidgetMetadata,
} from "mcp-use/react";
import { Brand } from "../shared/branding";
import { z } from "zod";

// ── Schema ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -75,9 +76,10 @@ export default function CourseCatalog() {
if (isPending) {
return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
<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" />
<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)]" />
<p className="m-0 text-sm">Browsing catalog…</p>
</div>
</div>
Expand Down Expand Up @@ -114,6 +116,7 @@ export default function CourseCatalog() {

return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="mx-auto max-w-[820px] bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
<div className="mb-4.5 flex flex-wrap items-baseline justify-between gap-2">
Expand Down Expand Up @@ -201,12 +204,12 @@ export default function CourseCatalog() {
<button
onClick={() => handleEnroll(course.id, course.title)}
disabled={pendingIds.has(course.id)}
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"
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)]"
>
{pendingIds.has(course.id) ? "Enrolling…" : "Enroll"}
</button>
) : course.has_access ? (
<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">
<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)]">
Access
</span>
) : (
Expand Down
9 changes: 6 additions & 3 deletions mcp-server/resources/course-dashboard/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useWidgetTheme,
type WidgetMetadata,
} from "mcp-use/react";
import { Brand } from "../shared/branding";
import { z } from "zod";

// ── Schema ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -60,7 +61,7 @@ function statusColor(status: string): string {
case "draft":
return "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400";
case "archived":
return "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400";
return "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]";
default:
return "bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400";
}
Expand All @@ -78,9 +79,10 @@ export default function CourseDashboard() {
if (isPending) {
return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
<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" />
<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)]" />
<p className="m-0 text-sm">Loading courses…</p>
</div>
</div>
Expand All @@ -99,6 +101,7 @@ export default function CourseDashboard() {

return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="min-h-0 bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
{/* Header */}
Expand All @@ -122,7 +125,7 @@ export default function CourseDashboard() {
onClick={() => setActiveFilter(s)}
className={`cursor-pointer rounded-full border px-3 py-[5px] text-xs transition-all duration-150 ${
active
? "border-violet-600 bg-violet-50 font-semibold text-violet-600 dark:border-violet-400 dark:bg-violet-950 dark:text-violet-400"
? "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)]"
: "border-zinc-200 bg-transparent font-normal text-zinc-500 dark:border-zinc-800 dark:text-zinc-400"
}`}
>
Expand Down
13 changes: 8 additions & 5 deletions mcp-server/resources/course-detail/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useCallTool,
type WidgetMetadata,
} from "mcp-use/react";
import { Brand } from "../shared/branding";
import { z } from "zod";

// ── Schema ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -63,7 +64,7 @@ function statusPill(status: string): string {
case "draft":
return "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400";
case "archived":
return "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400";
return "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]";
default:
return "bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400";
}
Expand Down Expand Up @@ -115,9 +116,10 @@ export default function CourseDetail() {
if (isPending) {
return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
<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" />
<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)]" />
<p className="m-0 text-sm">Loading course…</p>
</div>
</div>
Expand All @@ -135,6 +137,7 @@ export default function CourseDetail() {

return (
<McpUseProvider autoSize>
<Brand />
<div className={dark ? "dark" : ""}>
<div className="bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
{/* Course header */}
Expand Down Expand Up @@ -174,9 +177,9 @@ export default function CourseDetail() {
<button
onClick={handleLoadStats}
disabled={statsLoading}
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 ${
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)] ${
statsVisible
? "bg-violet-50 dark:bg-violet-950"
? "bg-[var(--brand-50)] dark:bg-[var(--brand-950)]"
: "bg-transparent"
}`}
>
Expand Down Expand Up @@ -241,7 +244,7 @@ export default function CourseDetail() {
key={lesson.id}
className="flex items-center gap-2.5 rounded-lg bg-zinc-100 px-2.5 py-2 dark:bg-zinc-800"
>
<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">
<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)]">
{lesson.sequence}
</span>
<span className="flex-1 text-[13px] font-medium text-zinc-900 dark:text-zinc-100">
Expand Down
Loading
Loading