Skip to content

feat(mcp): render lesson MDX in the lesson widgets the way the app does - #561

Merged
guillermoscript merged 2 commits into
masterfrom
feat/mcp-lesson-mdx-render
Jul 26, 2026
Merged

feat(mcp): render lesson MDX in the lesson widgets the way the app does#561
guillermoscript merged 2 commits into
masterfrom
feat/mcp-lesson-mdx-render

Conversation

@guillermoscript

@guillermoscript guillermoscript commented Jul 26, 2026

Copy link
Copy Markdown
Owner

What

The MCP lesson widgets (lesson-viewer, lesson-preview) now render lessons.content with the same component set the web app uses, instead of running MDX through a plain markdown renderer.

Closes #

Why

lessons.content is MDX — markdown plus the ~30 JSX components registered in components/lesson/mdx-components.tsx (<Callout>, <Quiz>, <Steps>, <Definition>, <Table>, <Comparison>, <FlashcardSet>, <Ordering>, <Video>, <LessonCheckpoint>, …). The web app compiles it with next-mdx-remote-client and hands <MDXClient> that map.

Both widgets instead passed the same string to react-markdown, which knows nothing about those tags — so a student reading a lesson through the MCP server got the raw source printed at them, JSON.parse('…') payloads and all (see the before shot). embed_code was never sent to the widget at all, and video_url rendered as a bare link rather than a player.

Why parse instead of compile: widgets can not compile-and-run MDX — @mdx-js/mdx's run() (and next-mdx-remote-client's MDXClient) build the module with the Function constructor, which the widget CSP sandbox blocks. So this parses only (remark + remark-mdx keeps JSX as mdxJsxFlowElement nodes) and renders that mdast tree with React. JSX attribute expressions are resolved without a JS runtime, including the {JSON.parse('…')} form components/teacher/block-editor/serializer.ts emits.

How

  • mcp-server/resources/shared/lesson/ (new)
    • mdx.ts — mdast parse + attribute resolution (JSON.parse('…'), literals, bare attrs)
    • components.tsx — widget port of the app's lesson components, app tokens mapped to the widget zinc/violet palette
    • renderer.tsx — mdast → React, plus the tag→component map mirroring lessonMdxComponents
    • index.tsx<LessonBody>: video → embed → MDX, the same order as lesson-content.tsx
  • Both widgets call <LessonBody>; lms_get_lesson now selects and passes embed_code (the student path already carried it).
  • embed_code is rendered sandboxed (sandbox="allow-scripts allow-popups"), never injected — the widget host is not the tenant's own page, so the app's trusted mode does not apply.
  • Failure modes are contained: unknown tags keep their text, an unparseable lesson falls back to its source with a notice, and an error boundary stops one bad component from blanking the lesson.
  • <LessonCheckpoint> renders a marker rather than an answerable exercise — checkpoint attempts are server-write-only and entitlement-gated (Lesson-checkpoint attempt route gates on a self-issuable enrollment row, bypassing entitlements and the access cutoff (#509 follow-up) #532/Lock the write side: grades, progress and practice rows are self-issuable #543), and the exercise renderer needs the lesson's checkpoint provider.

Deliberate differences from the app

  • No Shiki highlighting in code blocks (it would add ~1 MB to each widget); the code block keeps its filename/language header and copy button.
  • Widget strings are English; the app's lesson components hardcode Spanish regardless of locale.
  • The widget honours <Spoiler label> and <Vocabulary audioUrl> — the names the block editor actually serializes. The app's components only read title / audio, so those authored values are silently dropped there. Pre-existing app bug, worth its own issue.

Cost: +280 KB raw (+70 KB gzip) per lesson widget bundle for remark-mdx.

How to QA

  1. cd mcp-server && npm install && npm run build — both lesson widgets build.
  2. npx vitest run tests/unit/mcp-lesson-mdx.test.ts — 15 tests covering attribute resolution and a full render of a serializer-shaped lesson.
  3. In-app: as creator@codeacademy.com (code-academy.lvh.me:3000), author a lesson in the visual editor with a callout, a quiz and a table; then call lms_view_lesson / lms_get_lesson through the MCP server and confirm the widget shows the rendered components rather than source text.

Screenshots

Same lesson source, rendered by the widget before and after. Files are in the branch at docs/images/mcp-lesson-render-*.png if the embeds do not load.

Before — authored components printed as source:

before

After (light):

after

After (dark):

after dark

In the inspector, with realistic data

Run through mcp-use dev + mcp-use client screenshot against a throwaway no-auth entry, so the widget renders exactly as an MCP client shows it.

Lesson 2008 from the local seed (plain markdown — code blocks keep their language header and copy button):

seeded lesson

A lesson authored in the visual block editor — callout, steps, table, definition, quiz, ordering, fill-in-the-blank, spoiler, comparison, flashcards, checkpoint, file download, video:

authored lesson

Same lesson, dark:

authored lesson dark

Teacher-side lesson-preview widget, same content plus attached resources (the YouTube embed loads for real here):

teacher preview

The inspector run caught a bug the static tests missed: steps rendered 1, 3, 5 because <Step> took its number from a counter bumped during render, which React's development double-invoke counts twice. Fixed in ea1e7c17 by numbering in the mdast walk. The web app's components/lesson/steps.tsx has the same shape of bug (module-level counter bumped in render) — pre-existing, worth its own issue.

Checklist

  • npm run typecheck (mcp-server tsc --noEmit) and npm run test:unit pass — 478 unit tests green
  • mcp-use build passes (the Next npm run build is untouched by this change)
  • No new queries; the one changed select (lms_get_lesson) keeps its existing ownership check
  • Tested for the student path (lms_view_lesson) and the teacher path (lms_get_lesson)
  • Loading and error states handled (widget pending state, parse-failure fallback, render error boundary)
  • No new app UI strings (widget copy lives in the widget, like the rest of mcp-server/resources)
  • No migration

🤖 Generated with Claude Code

https://claude.ai/code/session_01QpttCAB3gFD9H5WFHG4boW

guillermoscript and others added 2 commits July 26, 2026 20:50
`lessons.content` is MDX — markdown plus the ~30 JSX components the web app
maps in `components/lesson/mdx-components.tsx` (`<Callout>`, `<Quiz>`,
`<Steps>`, `<Table>`, `<Video>`, `<LessonCheckpoint>`, …). The two MCP lesson
widgets ran that source through plain react-markdown, so every authored
component reached the student as literal source text, `JSON.parse('…')`
payloads included, and `embed_code` was dropped entirely.

Widgets can not compile-and-run MDX: `@mdx-js/mdx`'s run() builds the module
with the Function constructor, which the widget CSP sandbox blocks. So parse
only — remark + remark-mdx keeps JSX as mdxJsxFlowElement nodes — and render
that mdast with React. JSX attribute expressions are resolved without a JS
runtime, including the `{JSON.parse('…')}` form the block editor serializes.

- `resources/shared/lesson/`: mdast parser + attribute resolver, the widget
  port of the app's lesson component map, and the mdast→React renderer
- both lesson widgets now render video / embed_code / MDX in the same order as
  `lesson-content.tsx`; `lms_get_lesson` selects and passes `embed_code`
- unknown tags keep their text, an unparseable lesson falls back to its source,
  and an error boundary stops one bad component from blanking the lesson
- `LessonCheckpoint` renders a marker: checkpoint attempts are server-write-only
  and need the lesson's checkpoint provider, neither of which exists in a widget

Bundle cost is ~+280 KB raw (~+70 KB gzip) per lesson widget for remark-mdx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpttCAB3gFD9H5WFHG4boW
…nter

Running the widgets in the inspector with a three-step lesson showed 1, 3, 5:
Step took its number from a counter bumped while the component rendered, and
React's development double-invoke counted every step twice. The web app has the
same shape of bug — `components/lesson/steps.tsx` bumps a module-level counter
during render — so this is not a port artifact.

Numbering now happens in the mdast walk, which runs once per render call:
<Steps> opens a scope and each <Step> the walk reaches takes the next number.
Position also has to be read from the whole subtree, not from direct children —
an indented `  <Step>` parses as an inline mdxJsxTextElement inside a paragraph.

Screenshots added from an inspector run over a real seeded lesson and a lesson
authored with the block editor's component set, light and dark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpttCAB3gFD9H5WFHG4boW
@guillermoscript
guillermoscript force-pushed the feat/mcp-lesson-mdx-render branch from ea1e7c1 to aa7cf3c Compare July 26, 2026 18:52
@guillermoscript
guillermoscript marked this pull request as ready for review July 26, 2026 18:53
@guillermoscript
guillermoscript merged commit 2753c26 into master Jul 26, 2026
2 checks passed
@guillermoscript
guillermoscript deleted the feat/mcp-lesson-mdx-render branch July 26, 2026 18:56
guillermoscript added a commit that referenced this pull request Jul 27, 2026
`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
guillermoscript added a commit that referenced this pull request Jul 27, 2026
* 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

* test(mcp): retarget the step-number selector at the brand ramp

`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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant