Skip to content

fix(mcp): widget error boundaries, real pagination, and the last hardcoded English - #592

Open
guillermoscript wants to merge 2 commits into
masterfrom
fix/mcp-widget-hardening
Open

fix(mcp): widget error boundaries, real pagination, and the last hardcoded English#592
guillermoscript wants to merge 2 commits into
masterfrom
fix/mcp-widget-hardening

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

What

Hardening sweep over the 23 MCP widgets and the four list tools that feed them: a render error now shows a card instead of blanking the iframe, six list widgets can page past their first 20 rows, and the two widgets still hardcoding English got translated.

No new tools, no schema changes, no migration.

Why

Three separate ways the widgets were misleading the reader:

A crash was invisible. A widget renders in a bare iframe with nothing above it — an uncaught render error does not fall back to the chat transcript, it blanks the frame, and an empty box is indistinguishable from a slow tool. Not hypothetical: shared/lesson/renderer.tsx and shared/markdown.tsx walk mdast trees built from author-supplied lesson content, so one unexpected node shape takes the whole view down.

Page 1 was presented as the whole list. Every list tool takes limit/offset (default 20) and reports an exact count, so a widget truthfully rendered "50 courses" above 20 cards, with no control to press and no hint that this was a page. has_more/next_offset were computed for the markdown-returning tools and dropped by the widget-returning ones. lms_list_my_certificates was worse than paginated: a bare .limit(100) meant certificate 101 was reachable from nowhere, and total counted only what the page returned.

Spanish hosts read English. confusion-hotspots and flashcards had their labels inline instead of in STRINGS, so they ignored the host locale that every other widget respects.

How

  • mcp-server/resources/shared/error-boundary.tsxwithWidgetBoundary(Widget) wraps the default export, so it also covers the loading branch, the empty branch and every early return, not just one JSX tree. The fallback is themed and translated (hooks live in the wrapper; a class component can read neither). Async failures deliberately stay the widget's own job — a rejected callToolAsync never reaches a boundary, and a failed tool call is a state a widget can render around.
  • mcp-server/resources/shared/paging.tsxusePagedItems re-calls the widget's own tool at a higher offset and appends locally. Because widget({ props }) puts props on structuredContent, the next page arrives in exactly the shape already being rendered, and the host is never asked to re-render, so scroll position and local UI state survive. A result it cannot parse is treated as a failure, not as end-of-list, so nothing truncates silently.
  • Tools now echo offset, limit, has_more and the filter arguments a next page has to repeat (status, search, include_revoked, exam_id) — otherwise "load more" would quietly widen a filtered query to the full catalog. lms_list_my_certificates moved to .range() with count: 'exact'.
  • The CSS-grid pseudo-tables in exam-submissions and course-certificates became real <table>s with <caption> and scope="col"; table-fixed + <colgroup> reproduce the old grid widths exactly, so nothing moved visually. The expand control in exam-submissions is now a <button> in the first cell rather than role="button" on the <tr>, which had flattened four cells into one unlabelled announcement.
  • A failed submission-detail fetch shows a retry instead of "No additional detail available", which read identically to a submission that genuinely has none.

How to QA

cd mcp-server && npm run build && npm run dev, then open the mcp-use inspector. Restart dev before each check — HMR does not rebuild the served widget bundle.

  1. Paging — as creator@codeacademy.com (admin, code-academy.lvh.me:3000), call lms_list_courses with limit: 2. The footer reads "Showing 2 of N" with a Load more; press it — rows append, the count climbs, and the control disappears on the last page. Repeat with a status filter and confirm the appended rows still honour it.
  2. Certificate cap — as alice@student.com, lms_list_my_certificates with limit: 1: header total is the true count (not 1), and Load more walks the rest.
  3. Locale — set the host locale to es and open confusion-hotspots (teacher) and flashcards (student): every label, stat and empty state is Spanish, numbers are locale-formatted. The sendFollowUpMessage payloads stay English on purpose — they address the assistant, not the reader.
  4. Error boundary — temporarily throw new Error('boom') at the top of any widget, rebuild: an amber "This view could not be displayed" card appears in the frame's theme with a working Try again and collapsed details, instead of an empty iframe.
  5. A11y — with VoiceOver on exam-submissions: the table announces its caption, each cell is announced under its column name, and the row's expand control is reachable by keyboard.

Screenshots / GIF

To follow — widget QA shots are captured against a running mcp-use dev, and I'd rather attach them from a clean rebuild than from the working tree.

Checklist

  • npm run typecheck and npm run test:unit pass (675 tests) — plus mcp-server tsc --noEmit and npm run build (21 widgets)
  • npm run build passes
  • Every new tenant-scoped query filters by tenant_idlms_list_my_certificates keeps its user_id + tenant_id filters through the .range() change
  • Tested with every relevant role — student (my-certificates, flashcards, my-exam-results), teacher/admin (course-catalog, exam-submissions, confusion-hotspots, student-progress-roster)
  • Loading and error states handled — that is most of this PR
  • New UI strings added to both messages/en.json and messages/es.json — n/a: widgets ship their own STRINGS maps (en/es), both populated; they cannot read next-intl
  • Migration — none

Also carries one unrelated commit: chore(skills): install the improve skill (.agents/skills/improve + skills-lock.json), same as every other tracked skill.

🤖 Generated with Claude Code

guillermoscript and others added 2 commits July 30, 2026 02:25
…panish

Three gaps that all show up as "the widget is lying to me".

A widget renders in a bare iframe: an uncaught error blanks the frame with
no fallback to the transcript, and the lesson/markdown renderers walk
author-supplied mdast trees, so one odd node takes the view down. New
`shared/error-boundary.tsx` wraps every widget's default export — outside
the component, so the loading and empty branches are covered too — and
shows a themed, translated card with a retry and collapsed details.

Every list tool takes limit/offset (default 20) and reports an exact
count, so widgets truthfully said "50 courses" while rendering 20 cards
and offered no way to reach 21-50. New `shared/paging.tsx` appends pages
by re-calling the widget's own tool with a higher offset (props ride on
structuredContent), keeping local state and scroll position; the tools now
echo offset/limit/has_more and the filter args a next page must repeat.
`lms_list_my_certificates` was worse than paginated — a bare `.limit(100)`
meant certificate 101 was reachable from nowhere and `total` counted only
the page; it now uses a real range with an exact count.

Also: hardcoded English in confusion-hotspots and flashcards moved into
STRINGS + `fmt.number` (Spanish hosts were reading English labels); the
CSS-grid pseudo-tables in exam-submissions and course-certificates became
real tables with captions and column scopes, so a screen reader gets named
cells instead of four unlabelled runs of text per row; and a failed detail
fetch now shows a retry instead of "no additional detail available",
which was indistinguishable from a submission that genuinely has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read-only codebase advisor that emits handoff plans; tracked alongside the
other skills in .agents/skills so the lockfile stays honest.

Co-Authored-By: Claude Opus 5 <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