Skip to content

feat(mcp): nested-struct render + bundle htpy + cells-as-presentation#803

Merged
Andrew Gazelka (andrewgazelka) merged 10 commits into
mainfrom
fix/nested-struct-render
Jun 7, 2026
Merged

feat(mcp): nested-struct render + bundle htpy + cells-as-presentation#803
Andrew Gazelka (andrewgazelka) merged 10 commits into
mainfrom
fix/nested-struct-render

Conversation

@andrewgazelka

@andrewgazelka Andrew Gazelka (andrewgazelka) commented Jun 7, 2026

Copy link
Copy Markdown
Member

What

1. Nested Struct/List(Struct) cells render as boxed sub-tables. The dashboard's polars renderer (view.df_html) truncated any non-scalar cell to a 60-char str(value), so struct/list columns showed a clipped python repr. Now they render nushell-style: Struct → key/value table, List(Struct) → a table (column per field, row per element), scalar list → single column, capped at 50 rows.

2. Bundle htpy for auto-escaping HTML. Hand-rolled f-string HTML is where escaping gets forgotten (this PR had to patch exactly such an XSS — see below). htpy builds HTML as div(class_="x")[...] with every text node and attribute auto-escaped via markupsafe. Not in nixpkgs, so packaged inline (pure Python, one dep). The kernel instructions now point at it as the preferred way to compose dashboard HTML.

3. MCP instructions: cells is the final presentation of current state, not an append-only log — prune stale cells (cells.set/.remove/.clear).

4. Drive-by lint fix (packages/claude-code/default.nix): lib.recursiveUpdate → direct import lib/util/deep-merge.nix .rhs. The no-recursive-update rule was failing repo-wide nix run .#lint on main (from #799). Imported directly (not via the ix scope) so claude-code still evaluates from the OCI-image call sites that inject no ix.

Security

Adversarial review found a stored-XSS blocker (now fixed): the column-header dtype string was interpolated unescaped, and for nested dtypes that embeds struct field names verbatim — an agent building a frame from untrusted data could inject live HTML into the dashboard. Now _html.escaped, with a regression test. The htpy bundling addresses the root cause (no more hand-escaped f-strings).

Validation

  • nix build .#mcp, .#claude-code
  • nix build .#mcp.tests.viewSmoke (nested render + S1 escape) and .#mcp.tests.htpyBundled (import + auto-escape)
  • nix run .#lint (5/5, previously red on main)
  • ✅ x86_64-linux eval of the previously-broken image checks
  • Two rounds of adversarial code-review (render+deepMerge, then S1+import); a third on htpy is running

🤖 Generated with Claude Code (Opus 4.8)

Note

Render nested Struct/List DataFrame cells as HTML sub-tables with htpy bundled

  • Adds _fmt_nested in view/init.py to render Struct, List, and Array dtype cells as nested HTML tables instead of truncated string representations; lists are capped at 50 rows via _MAX_NESTED_ROWS.
  • Escapes struct field names and dtype strings in column headers to prevent HTML injection from attacker-controlled data.
  • Bundles the htpy Python package (v26.5.1) into the MCP environment via a new Nix derivation in default.nix, providing auto-escaping HTML construction.
  • Updates MCP tool instructions in tools.py to frame dashboard cells as current-state presentation and recommend htpy over f-string HTML.
  • Behavioral Change: nested cells now render as <table> elements and are left-aligned; previously they displayed as truncated strings.

Macroscope summarized e732f83.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@andrewgazelka Andrew Gazelka (andrewgazelka) changed the title fix(mcp): render nested Struct/List cells as boxed sub-tables feat(mcp): nested-struct render + bundle htpy + cells-as-presentation Jun 7, 2026
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Blast radius

35 of 1112 checks would rebuild between base 8032c80 and head f6753cc.

1 added, 0 removed

pie showData title Rebuilt checks by category
  "rust" : 20
  "image" : 13
  "blast" : 1
  "lint" : 1
Loading
flowchart LR
  c0["htpy-26.5.1.tar.gz"]
  c1["ix-mcp-view-python-module"]
  c2["ix-notebook-mcp-module"]
  c3["blast-radius-test"]
  c4["lint"]
  c5["rust-mcp.viewSmoke"]
  c0 --> k1["image-kernel-dev"]
  c0 --> k2["image-minecraft"]
  c0 --> k3["image-minecraft-bedrock"]
  c0 --> k4["image-minecraft-status"]
  c0 --> k5["image-minecraft_1.21.11-fabric"]
  c1 --> k1["image-kernel-dev"]
  c1 --> k2["image-minecraft"]
  c1 --> k3["image-minecraft-bedrock"]
  c1 --> k4["image-minecraft-status"]
  c1 --> k5["image-minecraft_1.21.11-fabric"]
  c2 --> k1["image-kernel-dev"]
  c2 --> k2["image-minecraft"]
  c2 --> k3["image-minecraft-bedrock"]
  c2 --> k4["image-minecraft-status"]
  c2 --> k5["image-minecraft_1.21.11-fabric"]
Loading
changed checks (34)
  • blast-radius-test
  • image-kernel-dev
  • image-minecraft
  • image-minecraft-bedrock
  • image-minecraft-status
  • image-minecraft_1.21.11-fabric
  • image-minecraft_1.21.11-paper
  • image-minecraft_26.1.2-fabric
  • image-minecraft_26.1.2-paper
  • image-minecraft_26w17a-fabric
  • image-minestom
  • image-neovim-ci
  • image-remote-desktop
  • image-test-cluster-bootstrap
  • lint
  • rust-mcp.bindDefaultSmoke
  • rust-mcp.bindingsSmoke
  • rust-mcp.dataLibsBundled
  • rust-mcp.engineBundled
  • rust-mcp.evalSmoke
  • rust-mcp.exaBundled
  • rust-mcp.fffBundled
  • rust-mcp.fleetSmoke
  • rust-mcp.gmailLibsBundled
  • rust-mcp.googleAuthBundled
  • rust-mcp.ixGoogleBundled
  • rust-mcp.nixSmoke
  • rust-mcp.richSmoke
  • rust-mcp.runtimeSmoke
  • rust-mcp.searchBundled
  • rust-mcp.serverTools
  • rust-mcp.shSmoke
  • rust-mcp.tuiBundled
  • rust-mcp.viewSmoke

The view df_html renderer fell back to a 60-char-truncated str(value) for
any non-scalar dtype, so Struct and List(Struct) columns showed a clipped
python repr ("[{'mount': '/', ...") instead of the data. Add a recursive
nested renderer: a Struct becomes a 2-col key/value table, a List(Struct)
becomes a real table (one column per field, one row per element), and a
list of scalars becomes a single-column table, capped at 50 rows. Matches
how nushell shows nested data.

Also sharpen the MCP instructions: cells is the final presentation of the
current state, prune stale cells rather than appending a log.
The no-recursive-update ast-grep rule (added with the deep-merge helpers)
flags lib.recursiveUpdate because it silently rhs-replaces at leaf
collisions. The settingsDefaults merge wants exactly rhs-wins (defaults
layered on top win), so switch to the explicit ix.deepMerge.rhs helper.
Unblocks repo-wide nix run .#lint.
Extend the view smoke test (viewTestPy) to cover the new nested renderer:
a List(Struct) cell must surface its inner field values in the HTML and
must not fall back to a truncated str(value) repr.
- S1 (security): the column-header dtype string was interpolated unescaped;
  for nested dtypes it embeds struct field names verbatim, so an agent
  building a frame from untrusted data could inject live HTML into the
  dashboard. Escape it (_html.escape(str(dt))) and add a regression test.
- Fix OCI image eval break: claude-code is called via callPackageWith with
  no 'ix' in scope (image-development-base/-symphony-codex), so requiring an
  'ix' arg failed eval. Import lib/util/deep-merge.nix directly instead.
- Harden the nested-render test: assert sub-table structure, not the '…'
  glyph the renderer legitimately emits.
Hand-rolled f-string HTML is where escaping gets forgotten (the dtype-header
XSS this set just patched). Bundle htpy (htpy.dev): build HTML as
div(class_='x')[...] with every text node and attribute auto-escaped via
markupsafe. Not in nixpkgs, so package it inline (pure Python, one dep).
Add an import+auto-escape smoke test and point the kernel instructions at it
as the preferred way to compose dashboard HTML.
@andrewgazelka Andrew Gazelka (andrewgazelka) merged commit e6d8942 into main Jun 7, 2026
11 of 12 checks passed
@andrewgazelka Andrew Gazelka (andrewgazelka) deleted the fix/nested-struct-render branch June 7, 2026 05:15
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