Skip to content

feat(ui): @dtpr/ui component library + MCP render_datachain tool#267

Merged
pichot merged 12 commits intomainfrom
feat/dtpr-component-lib
Apr 17, 2026
Merged

feat(ui): @dtpr/ui component library + MCP render_datachain tool#267
pichot merged 12 commits intomainfrom
feat/dtpr-component-lib

Conversation

@pichot
Copy link
Copy Markdown
Member

@pichot pichot commented Apr 17, 2026

Summary

New @dtpr/ui workspace package (packages/ui/) — a presentation SDK with three subpath exports that a single MCP tool in @dtpr/api uses to render interactive datachain iframes.

Subpath Contents
@dtpr/ui/core Framework-neutral primitives: extract, interpolate/interpolateSegments, category grouping/sort, deriveElementDisplay, validateDatachain, hexagon fallback
@dtpr/ui/vue Six Vue 3 SFCs (DtprIcon, DtprElement, DtprElementDetail, DtprCategorySection, DtprDatachain, DtprElementGrid) + single compiled styles.css with @layer dtpr + CSS tokens + container queries
@dtpr/ui/html renderDatachainDocument() — Vue-SSR wrapper (Path B from Unit 0.5 spike) that emits a self-contained <!doctype html> doc with inline styles + vanilla-JS accordion

New @dtpr/api/schema and @dtpr/api/validator subpaths emit .js/.cjs/.d.ts via tsup (Zod bundled) so the library boundary exports only inferred TS types. The Worker build and wrangler entry are untouched. api/src/mcp/ adds a self-contained MCP server bootstrap that mounts /mcp via @hono/mcp and registers a render_datachain tool + ui://dtpr/datachain/view.html app-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.vue rewrite, guide-app/admin migration, 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.css 4.49 kB / 1.03 kB gzip
  • pnpm --filter @dtpr/ui check:neutrality — R2 governance grep passes
  • pnpm --filter @dtpr/api test — 94 worker tests + 19 CLI tests pass; MCP handshake, tools/call, resources/read exercised end-to-end
  • pnpm --filter @dtpr/api build — wrangler dry-run succeeds; Worker bundle within Unit 0.5 budget
  • pnpm --filter @dtpr/api typecheck — clean

🤖 Generated with Claude Code

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 17, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
dtpr-docs b57e510 Apr 17 2026, 01:44 PM

pichot and others added 3 commits April 17, 2026 15:17
- 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-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 17, 2026

Greptile Summary

This PR introduces @dtpr/ui, a new presentation SDK with three subpath exports (core, vue, html), and adds a render_datachain MCP tool to @dtpr/api that renders interactive datachain iframes via Vue SSR. The session-scoped HTML slot mechanism (keyed by mcp-session-id) and the SafeHtml brand for emptyHtml are well-implemented mitigations for issues caught earlier. The hand-rolled JSON-RPC server is a pragmatic workaround for the Cloudflare Workers/SDK compatibility gap and is clearly documented.

Confidence Score: 5/5

Safe 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)

Security Review

  • DtprElementDetail.vue exposes a descriptionHtml prop bound to v-html without a SafeHtml brand; the prose-only "callers MUST sanitize" contract is not enforced at the type level. The current SSR rendering path is safe (prop is never passed), but direct Vue consumers have no compile-time guard against XSS.
  • isSafeUrl in DtprElementDetail.vue correctly blocks javascript: and non-HTTP schemes for URL-type variables before rendering as <a href>.
  • Variable values in the agent summary are wrapped in <dtpr_variable_value> tags to prevent prompt injection.
  • emptyHtml in document.ts now correctly uses the SafeHtml brand (addressed from prior review).
  • Session isolation for htmlSlots is properly keyed by mcp-session-id; cross-session bleed is prevented for well-behaved clients.

Important Files Changed

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

pichot added 4 commits April 17, 2026 15:18
- 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
Comment thread api/src/mcp/resources/datachain_resource.ts Outdated
Comment thread api/src/mcp/tools/render_datachain.ts
Comment thread packages/ui/src/html/document.ts
Comment thread api/src/mcp/resources/datachain_resource.ts Outdated
pichot added 3 commits April 17, 2026 15:21
… 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.
@pichot pichot force-pushed the feat/dtpr-component-lib branch from d5c211e to 360ab43 Compare April 17, 2026 13:30
pichot added 2 commits April 17, 2026 15:33
@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.
@pichot
Copy link
Copy Markdown
Member Author

pichot commented Apr 17, 2026

@greptile

@pichot pichot merged commit 4d6753f into main Apr 17, 2026
8 checks passed
@pichot pichot deleted the feat/dtpr-component-lib branch April 17, 2026 13:57
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