feat(mcp): render lesson MDX in the lesson widgets the way the app does - #561
Merged
Conversation
`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
force-pushed
the
feat/mcp-lesson-mdx-render
branch
from
July 26, 2026 18:52
ea1e7c1 to
aa7cf3c
Compare
guillermoscript
marked this pull request as ready for review
July 26, 2026 18:53
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>
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
The MCP lesson widgets (
lesson-viewer,lesson-preview) now renderlessons.contentwith the same component set the web app uses, instead of running MDX through a plain markdown renderer.Closes #
Why
lessons.contentis MDX — markdown plus the ~30 JSX components registered incomponents/lesson/mdx-components.tsx(<Callout>,<Quiz>,<Steps>,<Definition>,<Table>,<Comparison>,<FlashcardSet>,<Ordering>,<Video>,<LessonCheckpoint>, …). The web app compiles it withnext-mdx-remote-clientand 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_codewas never sent to the widget at all, andvideo_urlrendered as a bare link rather than a player.Why parse instead of compile: widgets can not compile-and-run MDX —
@mdx-js/mdx'srun()(andnext-mdx-remote-client'sMDXClient) build the module with theFunctionconstructor, which the widget CSP sandbox blocks. So this parses only (remark +remark-mdxkeeps JSX asmdxJsxFlowElementnodes) and renders that mdast tree with React. JSX attribute expressions are resolved without a JS runtime, including the{JSON.parse('…')}formcomponents/teacher/block-editor/serializer.tsemits.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 paletterenderer.tsx— mdast → React, plus the tag→component map mirroringlessonMdxComponentsindex.tsx—<LessonBody>: video → embed → MDX, the same order aslesson-content.tsx<LessonBody>;lms_get_lessonnow selects and passesembed_code(the student path already carried it).embed_codeis rendered sandboxed (sandbox="allow-scripts allow-popups"), never injected — the widget host is not the tenant's own page, so the app'strustedmode does not apply.<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
<Spoiler label>and<Vocabulary audioUrl>— the names the block editor actually serializes. The app's components only readtitle/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 forremark-mdx.How to QA
cd mcp-server && npm install && npm run build— both lesson widgets build.npx vitest run tests/unit/mcp-lesson-mdx.test.ts— 15 tests covering attribute resolution and a full render of a serializer-shaped lesson.creator@codeacademy.com(code-academy.lvh.me:3000), author a lesson in the visual editor with a callout, a quiz and a table; then calllms_view_lesson/lms_get_lessonthrough 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-*.pngif the embeds do not load.Before — authored components printed as source:
After (light):
After (dark):
In the inspector, with realistic data
Run through
mcp-use dev+mcp-use client screenshotagainst 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):
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:
Same lesson, dark:
Teacher-side
lesson-previewwidget, same content plus attached resources (the YouTube embed loads for real here):Checklist
npm run typecheck(mcp-servertsc --noEmit) andnpm run test:unitpass — 478 unit tests greenmcp-use buildpasses (the Nextnpm run buildis untouched by this change)lms_get_lesson) keeps its existing ownership checklms_view_lesson) and the teacher path (lms_get_lesson)mcp-server/resources)🤖 Generated with Claude Code
https://claude.ai/code/session_01QpttCAB3gFD9H5WFHG4boW