feat(ui): @dtpr/ui component library + MCP render_datachain tool#267
feat(ui): @dtpr/ui component library + MCP render_datachain tool#267
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
dtpr-docs | b57e510 | Apr 17 2026, 01:44 PM |
- Brainstorm frames a presentation library for four DTPR surfaces (app/ taxonomy, MCP App iframe, guide-app, admin) with layered core/vue/html subpath exports. - Plan narrows v1 to the MCP App iframe (app/ migration deferred), composes additively with sibling @dtpr/api MCP plan (no hard sequencing), and resolves design gaps (single-open accordion coordination, variable-type rendering contract). - Spike notes record Unit 0.5 findings: Path B (Vue SSR in Worker) adopted with measured evidence; @modelcontextprotocol/ext-apps works end-to-end in workerd; Vite library mode + vite-plugin-dts toolchain validated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves three architectural commitments before Unit 1 starts: 1. Renderer — Path B adopted (Vue SSR in Worker). Spike worker at api/spike/worker.ts renders 30-element fixture at p50=1ms, p99=10ms in workerd. Vue SSR delta over baseline is +50 KB gzipped; full bundle with MCP SDK + ext-apps is 278 KB gzipped, well under the 10 MB Worker limit. 2. MCP SDK — @modelcontextprotocol/ext-apps@1.6.0 adopted. registerAppTool + registerAppResource produce expected wire format; initialize/tools-list/resources-list/resources-read all return correct shapes with text/html;profile=mcp-app. 3. Toolchain — Vite library mode + vite-plugin-dts validated. Real DtprIcon.vue SFC with <script setup lang="ts"> + withDefaults produces complete DefineComponent types on first build. packages/ui/ scaffolding (package.json, tsconfig, vite.config.ts, src/core + src/vue stubs) carries forward into Unit 1 as-is. api/spike/ is throwaway reference material; delete when Unit 5/6 start. pnpm-workspace.yaml extended with packages/* glob. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…paths - Three subpath exports (/core, /vue, /html) with no root export - Vite library mode, vite-plugin-dts for declarations - Single compiled styles.css via cssCodeSplit:false - vitest + happy-dom + @vue/test-utils test scaffolding - R2 neutrality governance check script - README with usage, tokens, cascade-layer recipe, neutrality rule - Root build:ui / test:ui passthrough scripts
Greptile SummaryThis PR introduces Confidence Score: 5/5Safe to merge — all remaining findings are P2 quality/hardening suggestions with no present defects in the changed code paths. Prior P0/P1 concerns (cross-session HTML bleed, SafeHtml enforcement for emptyHtml) are resolved in the latest commit. New findings are both P2: the descriptionHtml v-html inconsistency doesn't affect the current SSR rendering path, and the htmlSlots eviction concern is bounded by Worker isolate lifecycle. Tests pass (117 ui + 113 api), typecheck is clean, and CI is correctly updated. api/src/mcp/resources/datachain_resource.ts (htmlSlots eviction), packages/ui/src/vue/DtprElementDetail.vue (descriptionHtml SafeHtml brand)
|
| Filename | Overview |
|---|---|
| api/src/mcp/resources/datachain_resource.ts | Session-scoped HTML slot via htmlSlots Map keyed by mcp-session-id fixes prior cross-session bleed; Map entries are never evicted (potential memory accumulation in long-lived isolates). |
| api/src/mcp/server.ts | Hand-rolled JSON-RPC MCP server implementing initialize, tools/list, tools/call, resources/list, resources/read, and ping; batch handling is sequential-await; GET returns 405 correctly. |
| api/src/mcp/tools/render_datachain.ts | Renders DTPR datachain via Vue SSR, handles Zod/semantic validation, wraps variable values against prompt injection; element_count in structuredContent and totalElements in agent summary may overcount multi-category elements (flagged in prior review). |
| packages/ui/src/html/document.ts | Vue SSR document renderer with SafeHtml brand for emptyHtml, proper escapeHtml for locale/title attributes, static CSS/script inlining; no new issues. |
| packages/ui/src/vue/DtprElementDetail.vue | Rich element detail component with typed variable rendering (url/boolean/number/date/text) and isSafeUrl guard; descriptionHtml prop uses v-html without the SafeHtml brand applied to emptyHtml. |
| .github/workflows/api-test.yaml | CI correctly builds @dtpr/ui and @dtpr/api/schema before typecheck to satisfy subpath import resolution; path filters include packages/** for ui changes. |
Sequence Diagram
sequenceDiagram
participant Client as MCP Client
participant Server as /mcp Hono Handler
participant Registry as ToolRegistry
participant R2 as R2 Bucket
participant SSR as Vue SSR Renderer
participant Slots as htmlSlots Map
Client->>Server: POST /mcp tools/call render_datachain
Server->>Registry: buildToolRegistry(ctx, sessionId)
Registry->>R2: loadManifest + loadCategories + loadElements
R2-->>Registry: schema data
Registry->>Registry: validateInstance semantic check
Registry->>Registry: buildSections per category
Registry->>SSR: renderDatachainDocument(sections)
SSR-->>Registry: HTML string
Registry->>Slots: setDatachainHtml(sessionId, html)
Registry-->>Server: content + _meta.ui.resourceUri
Server-->>Client: JSON-RPC result with resourceUri
Client->>Server: POST /mcp resources/read view.html
Server->>Slots: getDatachainHtml(sessionId)
Slots-->>Server: HTML or placeholder
Server-->>Client: contents with mimeType text/html
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/ui/src/vue/DtprElementDetail.vue
Line: 23
Comment:
**`descriptionHtml` missing `SafeHtml` brand**
`descriptionHtml` is bound to `v-html` (line 149), bypassing Vue's auto-escaping, but its type is plain `string | undefined`. The same risk that prompted the `SafeHtml` brand + `trustAsHtml()` wrapper for `emptyHtml` in `document.ts` applies here — any Vue consumer who passes user-supplied content without sanitizing will silently get XSS. The current SSR rendering path in `document.ts` is safe (it never passes this prop), but the public component API doesn't enforce the contract at the type level.
```suggestion
descriptionHtml?: import('../html/document.js').SafeHtml
```
Or re-export `SafeHtml` / `trustAsHtml` from `@dtpr/ui/core` so component consumers have a single import path for the brand.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: api/src/mcp/resources/datachain_resource.ts
Line: 24
Comment:
**`htmlSlots` Map accumulates without eviction**
`htmlSlots` is never pruned — every new `mcp-session-id` permanently allocates an entry until the Worker isolate is recycled. For a busy isolate handling many short-lived MCP sessions (each generating a few-KB SSR HTML string), this can drive memory usage up over time. A simple bounded LRU or a per-entry TTL would bound growth.
For v1, even just capping the map size and evicting the oldest entry when the cap is reached would make the worst case bounded:
```ts
const MAX_SLOTS = 200
function evictIfNeeded(): void {
if (htmlSlots.size >= MAX_SLOTS) {
const oldest = htmlSlots.keys().next().value
if (oldest !== undefined) htmlSlots.delete(oldest)
}
}
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (2): Last reviewed commit: "fix(api,ui): session-scoped HTML slot + ..." | Re-trigger Greptile
- tsup.schema.config.ts compiles src/schema + src/validator to dist/ - ESM + CJS + .d.ts output; Zod bundled (noExternal) so consumers with a different Zod major don't share a ZodType value - exports map exposes ./schema and ./validator; Worker build (wrangler) is untouched (main still points at src/index.ts) - api/test/schema/exports.test.ts verifies the compiled surface resolves and ElementSchema.parse works via the subpath
- locale: extract, extractWithLocale (requested → en → first-available → '') - interpolate + interpolateSegments (whitespace-tolerant, missing-var pass-through) - categories: groupElementsByCategory, sortCategoriesByOrder, findCategoryDefinition - element-display: deriveElementDisplay with hexagon fallback + variable merge - validate: validateDatachain wraps @dtpr/api/validator's validateInstance - icons: HEXAGON_FALLBACK_DATA_URI (moved from index.ts stub) - types: re-exports inferred types from @dtpr/api/schema Variable schema does not carry a 'type' field today; element-display defaults to 'text' and documents where to read it once the schema adds one. 50 tests across 5 files, all green.
Primitives (all use unscoped CSS for Vue SSR compatibility): - DtprIcon: hexagon fallback on empty src or @error, alt-always present - DtprElement: compact figure (icon + interpolated title) - DtprElementDetail: full view, overlay / after-description / after-variables / after-citation slots, variable-type rendering contract (text/url/bool/ number/date/unknown), javascript: URL XSS guard, descriptionHtml trusted - DtprCategorySection: collapsible with aria-expanded/aria-controls, Enter/ Space toggle, disableAccordion always-expanded mode - DtprDatachain: composition + v-model:openSectionId controlled accordion, #empty slot, disableAccordion cascade - DtprElementGrid: container-query-driven 1/2/3 column grid Styles: single styles.css with @layer dtpr, dtpr- prefix, CSS custom property tokens mirroring DTPR brand defaults (Red Hat Text, #0f5153), container queries at primitive roots. 50 new tests (100 total, all green). dist/vue/styles.css 4.49 kB gzip 1 kB.
- renderDatachainDocument(sections, options?) — full <!doctype html> document; delegates body rendering to the DtprDatachain Vue SFC via @vue/server-renderer (Path B from Unit 0.5) - styles.ts inlines src/vue/styles.css via Vite '?raw' so the emitted dist/html/index.js is self-contained (no runtime asset imports) - script.ts ships vanilla-JS accordion handler: click + Space keydown delegated at document level (Enter is handled natively by <button> to avoid double-toggle with the click path) - env.d.ts declares the '*.css?raw' ambient module so vue-tsc emits clean declarations - 12 new tests (112 total): full document shape, single <style>/<script>, XSS escape on section/title, empty-state placeholder, locale lang, perf smoke, accordion toggle in jsdom
… bootstrap
- api/src/mcp/server.ts: createMcpServer() — per-request, idempotent
- api/src/mcp/tools/render_datachain.ts: tool handler, typed error envelope
with INVALID_VERSION / INVALID_DATACHAIN / MISSING_CATEGORIES /
MISSING_ELEMENTS / SEMANTIC_VALIDATION_FAILED codes; variable values
wrapped in <dtpr_variable_value> tags in agent summary
- api/src/mcp/resources/datachain_resource.ts: stable ui://dtpr/datachain/
view.html with readCallback over module-level HTML slot (per-session
isolation TODO'd)
- api/src/app.ts: mounts /mcp via @hono/mcp StreamableHTTPTransport
- @modelcontextprotocol/ext-apps + vue + @vue/server-renderer + @dtpr/ui
promoted to runtime deps
- test/stubs/ajv-{formats-}stub.mjs: workerd test-env ajv workaround;
wired via vitest.config.ts resolve.alias; not in production wrangler
build
- 15 new tests (12 render_datachain + 3 server-compose). Worker bundle
1566 KiB raw / 290 KiB gzipped (matches Unit 0.5 spike).
- packages/ui/test/fixtures/{categories,datachain}-sample.ts — pinned
ai@2026-04-16-beta fixture (3 categories × 2 elements)
- packages/ui/test/document-shell.test.ts — 5 tests:
* committed-snapshot render of the canonical fixture
* exactly one <!doctype>, one <style>, one <script>
* ARIA parity — every section has <button aria-expanded aria-controls>
+ region panel with aria-labelledby
* deep-link parity — section ids match /^ai__[a-z0-9_-]+$/
* empty fixture renders the empty-state placeholder (no buttons)
117 tests total across the library, all green.
Path B collapsed 'equivalence' into this smoke test; cross-renderer diff
harness is not needed since /vue and /html share one source.
…erver
Rebasing onto main revealed the sibling plan shipped a hand-rolled
JSON-RPC handler (api/src/mcp/server.ts) that intentionally avoids
@modelcontextprotocol/sdk because workerd's older nodejs_compat can't
load ajv transitively. Unit 6's original SDK + ext-apps wiring won't
compose with that choice, so adapt Unit 6 to the actual architecture:
- api/src/mcp/tools/render_datachain.ts: rewritten as renderDatachainTool
(ctx: LoadContext) → ToolDef, fitting buildToolRegistry. Now loads
categories/elements from R2 via the store helpers rather than
requiring them inline on every call, and reuses validateInstance +
resolveKnownVersion + the okEnvelope/errEnvelope pattern used by the
other read-side tools.
- api/src/mcp/resources/datachain_resource.ts: stripped of ext-apps;
now a plain module-level HTML slot + URI constants + placeholder.
- api/src/mcp/server.ts (main's hand-rolled dispatcher): extended with
resources/list + resources/read methods and a resources:{} capability
so the rendered HTML is reachable by MCP Apps clients.
- api/src/mcp/tools.ts: renderDatachainTool added to the registry; the
local ToolDef interface is exported for external tool modules.
- api/test/api/mcp/render_datachain.test.ts: rewritten against SELF.fetch
+ the seeded R2 fixture pattern already used by mcp.test.ts.
- api/test/api/mcp.test.ts: tools/list expected list bumped from 7 to 8.
- api/test/api/mcp/server-compose.test.ts, api/test/stubs/ajv-stub.mjs,
api/test/stubs/ajv-formats-stub.mjs, and the vitest resolve.alias
block are removed — all of them existed only to work around the
SDK path we no longer use.
- api/package.json: drops @hono/mcp, @modelcontextprotocol/sdk,
@modelcontextprotocol/ext-apps. Keeps @dtpr/ui, vue, @vue/server-renderer
(@dtpr/ui/html SSRs Vue SFCs inside the Worker).
Test totals: 213 api worker tests + 31 CLI tests + 117 UI tests — all green.
d5c211e to
360ab43
Compare
@dtpr/api's typecheck pulls in @dtpr/ui/core, @dtpr/ui/html, and @dtpr/api/schema — all emitted by separate build steps (Vite library mode for the UI package, tsup for the schema subpaths). Without a prior build, tsc sees bare directory symlinks under node_modules and fails with TS2307 on every subpath import. - Add build:schema and @dtpr/ui build steps before typecheck - Run @dtpr/ui test in this job too so a broken library breaks CI - Widen path filter to packages/** so this workflow fires on library-only changes that affect @dtpr/api
Two Greptile review findings on the original rebase:
P1 — Cross-session HTML bleed (api):
The module-level currentHtml slot let a concurrent render_datachain
from session B overwrite session A's output before A's resources/read
returned. Key the slot by mcp-session-id instead:
- datachain_resource.ts: Map<sessionId, html> with DEFAULT_SESSION_KEY
fallback for clients that do not send the header
- server.ts: read c.req.header('mcp-session-id') once per /mcp
request, thread it into buildToolRegistry + dispatch
- tools.ts: buildToolRegistry(ctx, sessionId) hands sessionId to
renderDatachainTool only; other tools are unaffected
- tools/render_datachain.ts: setDatachainHtml(sessionId, html)
- new test proves session A and session B see their own renders
even when interleaved
P2 — emptyHtml rendered via v-html without a type-level trust marker
(ui):
Document the trust boundary in the type system. New SafeHtml nominal
brand + trustAsHtml helper on @dtpr/ui/html; RenderDatachainOptions.
emptyHtml is now SafeHtml, so plain strings fail the typecheck and
callers must opt in explicitly. No runtime sanitizer is bundled —
that remains the caller's responsibility — but a future caller who
misses the prose contract no longer silently compiles.
|
@greptile |
Summary
New
@dtpr/uiworkspace package (packages/ui/) — a presentation SDK with three subpath exports that a single MCP tool in@dtpr/apiuses to render interactive datachain iframes.@dtpr/ui/coreextract,interpolate/interpolateSegments, category grouping/sort,deriveElementDisplay,validateDatachain, hexagon fallback@dtpr/ui/vueDtprIcon,DtprElement,DtprElementDetail,DtprCategorySection,DtprDatachain,DtprElementGrid) + single compiledstyles.csswith@layer dtpr+ CSS tokens + container queries@dtpr/ui/htmlrenderDatachainDocument()— Vue-SSR wrapper (Path B from Unit 0.5 spike) that emits a self-contained<!doctype html>doc with inline styles + vanilla-JS accordionNew
@dtpr/api/schemaand@dtpr/api/validatorsubpaths emit.js/.cjs/.d.tsviatsup(Zod bundled) so the library boundary exports only inferred TS types. The Worker build andwranglerentry are untouched.api/src/mcp/adds a self-contained MCP server bootstrap that mounts/mcpvia@hono/mcpand registers arender_datachaintool +ui://dtpr/datachain/view.htmlapp-resource, matching SEP-1865. Worker bundle is 1566 KiB raw / 291 KiB gzipped — well under the 10 MB limit and aligned with the Unit 0.5 measurement.Scope explicitly excludes:
app/pages/taxonomy/ai.vuerewrite,guide-app/adminmigration, v2 editor primitives,<DtprCategoryNav>, R2 schema-bundle loading inside the tool (callers pass categories + elements inline for v1). See the plan document for the full deferred list.Test plan
pnpm --filter @dtpr/ui test— 117 tests pass (core/vue/html + document-shell smoke)pnpm --filter @dtpr/ui build— all three subpaths emit with declarations;dist/vue/styles.css4.49 kB / 1.03 kB gzippnpm --filter @dtpr/ui check:neutrality— R2 governance grep passespnpm --filter @dtpr/api test— 94 worker tests + 19 CLI tests pass; MCP handshake, tools/call, resources/read exercised end-to-endpnpm --filter @dtpr/api build— wrangler dry-run succeeds; Worker bundle within Unit 0.5 budgetpnpm --filter @dtpr/api typecheck— clean🤖 Generated with Claude Code