Skip to content

feat(mcp): render charts as native interactive widgets via MCP Apps - #43483

Draft
aminghadersohi wants to merge 57 commits into
apache:masterfrom
aminghadersohi:research-mcp-native-viz-clients
Draft

feat(mcp): render charts as native interactive widgets via MCP Apps#43483
aminghadersohi wants to merge 57 commits into
apache:masterfrom
aminghadersohi:research-mcp-native-viz-clients

Conversation

@aminghadersohi

@aminghadersohi aminghadersohi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

Superset MCP tools that return a chart as a real, interactive visualization inside the AI chat instead of prose the model has to describe.

Built against the MCP Apps extension (SEP-1865, stable 2026-01-26) — the ui:// resource + _meta.ui.resourceUri mechanism co-authored by Anthropic, OpenAI and the mcp-ui authors. Verified working in Claude Desktop.

What it adds

Piece Location
render_chart, render_chart_requery, render_dashboard chart/tool/render_chart.py
ui:// resource serving the widget chart/resources/chart_viewer.py
Widget source (React + ECharts + Vite) chart/resources/chart_viewer/
@tool(meta=...) plumbing superset-core/.../mcp/decorators.py, core/mcp/core_mcp_injection.py

All three tools are thin wrappers over get_chart_data_core — the existing, already-authorized data path — so no new data or authorization path is introduced. Chart/dataset RBAC, guest-token scoping and RLS are re-applied on every interaction, including drill-down. The widget is static and tenant-neutral; per-user data reaches it only through tool results, never baked into the resource.

Two existing MCP defaults blocked MCP Apps and are handled without weakening them globally:

  1. StructuredContentStripperMiddleware strips structuredContent from every tool (a Claude-bridge workaround). A small keep-list (MCP_STRUCTURED_CONTENT_KEEP_TOOLS) exempts the render tools; everything else is still stripped.
  2. The tool-search transform hides tools behind search_tools/call_tool, which prevents widget association. The render tools are pinned in always_visible.

Known limitation, stated up front. Superset builds each chart's query in the frontend viz plugin (buildQuery), which supplies the time grain, sort, pivot structure and any second series. This service rebuilds the query in Python when a chart has no saved query_context, and that rebuild is lossy — a monthly chart can return individual days. Affected charts carry a visible notice in the widget rather than presenting the data as the chart's. Reusing Superset's own buildQuery/transformProps is the intended fix and is measured as viable (~355 KB for five chart types, ~16 KB marginal each); it is deliberately not in this PR.

Also fixed here (pre-existing, not MCP Apps specific): a single NULL in a temporal column made get_chart_data unserializable. pandas.NaT subclasses datetime.datetime, so pydantic_core reads .yearnan — and raises during serialization after the query has already succeeded. Null-ish scalars are now normalized at the two points rows leave the query result.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Before: render_chart did not exist; chart data reached the model as JSON and was described in prose.

After: the chart renders inline and interactively in the conversation — view switching, click-to-drill (re-queries live), dashboard grids with tabs.

Screenshots to follow — the widget requires an MCP Apps host to render.

TESTING INSTRUCTIONS

Unit tests

pytest tests/unit_tests/mcp_service/          # Python
cd superset/mcp_service/chart/resources/chart_viewer
npm ci && npm test && npm run build           # widget: 191 tests, tsc, bundle

CI also runs the widget suite via .github/workflows/mcp-chart-viewer.yml (path-filtered).

In a real MCP Apps host (full checklist in chart/CHART_VIEWER_HOST_VERIFICATION.md)

cd superset/mcp_service/chart/resources/chart_viewer && npm ci && npm run build
cd - && SUPERSET_CONFIG_PATH=$PWD/superset_config.py superset mcp run --port 5008 --debug

Connect Claude Desktop to the local MCP endpoint, then in a brand-new conversation:

  1. "Render Superset chart 113" → an interactive chart appears inline
  2. Click the line/bar/area chips → the chart morphs
  3. Click a data point → drill-down re-queries; the row count changes
  4. "Render Superset dashboard 5" → a grid of charts with working tabs
  5. "Render Superset chart 99999" → a readable error, not a protocol error

Hosts cache the ui:// bundle and the tool descriptor per conversation. Start a new conversation after any rebuild, and restart Desktop after restarting the MCP server — its proxy processes do not reconnect on their own.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

Notes for reviewers

  • The built widget (chart_viewer/dist/index.html) is not committed, matching the superset/static/assets convention: gitignored, produced at build time, shipped via MANIFEST.in. Until built, the ui:// resource serves a placeholder page that says so.
  • The widget is an npm project living inside the Python package so it sits next to the resource that serves it. Every other bundler project in this repo is a repo-root sibling; if this graduates, moving it is probably the more conventional shape.
  • Packaging: the PyPI path is wired (RELEASING/README.md); the Docker image is still a gap and official images would serve the placeholder.
  • Supersedes feat(mcp): render charts as native interactive widgets via MCP Apps #42543, which was closed when its head repository was deleted. Content is unchanged plus a merge with current master.

🤖 Generated with Claude Code


Environment note for anyone pulling this branch

This branch is merged with a master that bumps SQLAlchemy 1.4 → 2.0.51, so a pip install -r requirements/development.txt is required after pulling.

That install currently leaves fastmcp broken — fastmcp and fastmcp-slim 3.4.5 both land but fastmcp/exceptions.py goes missing. Repair with:

pip install --force-reinstall --no-deps fastmcp==3.4.5 fastmcp-slim==3.4.5

Both are environment-setup issues, not defects in this change.

aminghadersohi and others added 30 commits July 30, 2026 18:12
Add a `render_chart` MCP tool that returns chart data plus a
`_meta.ui.resourceUri` descriptor so MCP Apps hosts (Claude, ChatGPT,
VS Code Copilot, Cursor, Goose, ...) render the result as a real,
interactive ECharts visualization inline in the conversation instead of
a prose summary — the SEP-1865 MCP Apps standard.

Highlights:
- `render_chart` / `render_chart_requery` tools wrap the shared
  `get_chart_data_core`, preserving the existing Chart/read RBAC, dataset
  access, guest scoping and RLS on every call (incl. widget drill-down).
- `ui://superset/chart-viewer/v1` resource serves a self-contained
  React + ECharts widget (line/bar/area/big-number/table + styled-table
  fallback) with animated view morphing, click-to-drill, brush-to-zoom,
  and an "ask about this" model-context hook.
- `@tool(meta=...)` plumbing threads `_meta` onto tool descriptors.
- Scope the structured-content stripper via a keep-list so widget tools
  retain `structuredContent`; pin the widget tools past tool-search.
- The widget never renders sample data when embedded (shows a connection
  error), surfaces ChartError states, and treats unknown host
  capabilities as unsupported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ECharts renders a tooltip formatter's return value as HTML. formatFull()
falls back to String(value) for non-numeric input, so an unescaped data
value could inject markup into the tooltip. Escape it, and harden
escapeHtml() to cover quotes.

Adds a regression test asserting series names, axis values, dimension
labels and values are all escaped while ECharts' own marker markup is
preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MANIFEST.in did not cover superset/mcp_service, so the built widget
bundle was excluded from sdist/wheel builds — a pip-installed Superset
would serve the 'not built' fallback instead of the chart widget.
Include only the built artifact, not the widget source or node_modules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The widget hardcoded its palette (#20A7C9 — Superset's *previous* primary
color), so charts rendered in a chat client drifted from the customer's
configured branding. Consistency is the reason customers asked for
Superset theming, and it is the main complaint about AI-generated charts:
every regeneration looks different.

render_chart now forwards an allow-listed subset of the instance's antd
design tokens (colorPrimary, colorLink, status colors, fontFamily) on the
tool result, and the widget uses them for its accent, font and the lead
categorical series color. Falls back to the built-in palette when a
deployment has no theme configured.

Only presentational tokens are forwarded — no logo URLs, no secrets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committing dist/index.html put an 836 KB minified build artifact in the
source tree. That is out of step with how this repo already handles built
frontend assets — superset/static is gitignored (zero tracked files) and
shipped via MANIFEST.in at packaging time — and it inlines third-party
code (React, ECharts, d3), which a source tree should not carry.

Gitignore the build output and rely on the same packaging path. The
ui:// resource already degrades to a placeholder page that tells the
operator to run the build, so a source checkout stays functional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The widget's tests and build do not run in CI, and no release step
produces the bundle that MANIFEST.in expects. Document both, plus the
fact that the widget's location inside the Python package differs from
every other npm project in the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both widget tools are typed `-> ChartData | ChartError`, and FastMCP wraps
union returns in a synthetic envelope, so the payload on the wire is
`{"result": {...}}` (the outputSchema carries `x-fastmcp-wrap-result`).

`coerceToolResultData` returned that verbatim and `isChartData()` tests for
top-level `columns`/`data`, so the predicate was always false: the widget
rendered an error while its data sat one level down. The same wrapping hid
`ChartError` payloads, so a not-found produced neither data nor an error and
the widget spun forever.

Unwrap when `result` is the sole key, leaving any other payload untouched.

The existing contract test asserted the *unwrapped* shape — an assumption
about FastMCP's behavior rather than the observed wire format — which is why
it stayed green through the defect. Corrected to the real shape, with cases
for the wrapped form, the unwrapped form, and a payload where `result` is not
the only key. Verified end-to-end by capturing structuredContent from a live
in-process FastMCP call and parsing it with the widget's own bridge.

Reported by a sibling session testing against a live local MCP server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep-list tools keep their `outputSchema` advertised, but the stripper's
generic exception handler returned a text-only ToolResult with no
structured content. For every other tool that is harmless — their schema
was already stripped — but for `render_chart` / `render_chart_requery` a
strict client rejects the whole call:

    MCP error -32600: Tool render_chart has an output schema but did not
    return structured content

So every server-side error surfaced as an opaque protocol error and the
real cause was invisible to the user (observed in Claude Desktop).

Emit an `MCPBaseError`-shaped payload wrapped in the `result` envelope for
keep-list tools, matching the declared `ChartData | ChartError` union, so
failures arrive as readable messages the widget renders as its error state.

Reported by a sibling session testing against a live local MCP server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rols

The chart-viewer widget rendered the service's <UNTRUSTED-CONTENT> data-boundary
markers literally in the title, insight bar, big-number label, and drill
message; strip them at every display site while leaving escaping to React.

Add user-driven sizing: a drag-to-resize handle, a maximize toggle that first
requests the host's fullscreen display mode and falls back to growing in place,
and a size-changed report on data load so hosts that honor it can size the
frame.

Bump the widget resource URI to ui://superset/chart-viewer/v2: MCP Apps hosts
cache the bundle per conversation keyed by URI, so an unchanged URI keeps
serving stale copies after the widget changes.
…s' into research-mcp-native-viz-clients

# Conflicts:
#	superset/mcp_service/chart/resources/chart_viewer.py
#	superset/mcp_service/chart/resources/chart_viewer/src/App.tsx
#	superset/mcp_service/chart/resources/chart_viewer/src/adapter.ts
#	superset/mcp_service/chart/resources/chart_viewer/src/bridge.ts
#	superset/mcp_service/chart/resources/chart_viewer/src/format.test.ts
Everything about the widget is verified programmatically except whether it
renders. Only a person with a real MCP Apps host can close that, so this is
the minimal path from a clean machine to a screenshot, plus the 12 checks
worth walking and what to capture.

Leads with the per-conversation bundle caching rule, which is the single
biggest time sink when testing bundle changes, and states plainly that
nothing here may be marked verified from wire-level evidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The adapter suite asserts the shape of the option object, which keeps
passing even if ECharts rejects it at runtime — the blind spot that let the
ECharts 5 -> 6 major bump land visually unverified.

These tests call setOption on a live instance, so an option key ECharts no
longer accepts, an unregistered chart/component, or a breaking series
contract change fails here rather than in a host. Covers single- and
multi-metric line/bar/area, the activeMetrics subset, categorical bars, the
big-number sparkline, empty data, and bar->line->area switching on one
instance (the universalTransition morph path).

jsdom has no canvas, so tests register the SVG renderer. That exercises the
same option parsing, series construction and coordinate system; pixel output
remains out of scope and is covered by host verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MCP Apps chart widget under
superset/mcp_service/chart/resources/chart_viewer/ was not referenced by any
workflow, so its vitest suite and Vite build never ran in CI and would have
rotted silently.

Adds a path-filtered workflow modelled on superset-websocket.yml: npm ci,
npm test, npm run build (which also runs `tsc --noEmit`), plus an explicit
assertion that the single-file dist/index.html bundle was emitted, since that
file is what the ui://superset/chart-viewer resource serves.

Pins Node via a new .nvmrc matching the repo-wide v24.16.0.

Co-Authored-By: Claude <noreply@anthropic.com>
MANIFEST.in ships the widget's dist/index.html, but nothing produced it at
package time, so wheels/sdists shipped without the widget and the
ui://superset/chart-viewer resource fell back to a placeholder.

Investigated how superset/static/assets is produced: it is NOT built by
setup.py or pyproject.toml. The frontend build is a separate step owned by the
release process (RELEASING/README.md's "Create the distribution" block, run
before `python -m build`) and by the Dockerfile's superset-node stage. The
widget build is wired into the former, next to the existing frontend build.

Verified locally with setuptools 80.9.0 that the MANIFEST.in entry works: the
wheel contains dist/index.html when it exists, and the sdist contains only
dist/index.html — not package.json or src/ — so the widget cannot be rebuilt
from a released tarball and must be built beforehand.

The Docker image gap is left open and documented rather than papered over: the
Dockerfile has no widget build step, so official images still serve the
placeholder. The README spells out exactly what closing it requires.

Co-Authored-By: Claude <noreply@anthropic.com>
Investigated whether the server needs to declare the MCP Apps extension during
initialize. It already does, and not from Superset code: FastMCP's low-level
server unconditionally attaches

    "extensions": {"io.modelcontextprotocol/ui": {}}

to ServerCapabilities as a pydantic extra field (the type is extra="allow"),
the same mechanism it uses for `tasks`. Verified by round-tripping initialize
against the real superset.mcp_service.app.mcp instance on fastmcp 3.4.2 — the
extension is advertised on the wire with no code change, and is present even
for a bare FastMCP() with no experimental_capabilities.

FastMCP does accept experimental_capabilities={"io.modelcontextprotocol/ui":
...} without error, but it lands in the legacy `experimental` map next to the
already-correct `extensions` entry, giving hosts two sources of truth for one
capability. So app.py is left alone.

Since the declaration is an implicit SDK dependency, it is pinned by a test:
an SDK bump that drops it now fails CI instead of silently degrading every MCP
Apps host to a plain-text tool result. A companion test asserts the extension
is not mirrored under `experimental`.

Co-Authored-By: Claude <noreply@anthropic.com>
buildSparklineOption takes the {x, y} series that resolveBigNumber
produces, not a ChartData. vitest does not typecheck, so `npm test` was
green while `npm run build` (tsc --noEmit) failed — which would have
red-lit the new chart-viewer CI workflow on its first run.

Rewritten to go through the real resolveBigNumber -> buildSparklineOption
path that BigNumber.tsx uses, which is the more faithful test anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two defects made the chart widget misrender and its drill-down unusable.

Trust delimiters leaked into the rendering. `stripUntrustedMarkers` was
only applied to the chart title, drill label, and insight, so string cell
values and category axis labels displayed the raw
`<UNTRUSTED-CONTENT>` tags. Strip at the two display choke points
instead: the string branch of `formatByColumn` (table cells) and
`normalizeDim` (axis labels, tooltips, drill payloads).

The re-query call could never succeed. The widget sent a flat
`{chart_id, ...}` payload, but `render_chart_requery` takes a single
`request` model keyed by `identifier`; the server rejects the flat form
with a validation error. It also sent the raw, marker-wrapped dimension
value as the filter, which would match no rows even once the envelope
was accepted. Wrap the arguments correctly and strip the filter value.

Adds coverage for both display paths.
The widget shipped sending a flat {chart_id, filter, ...} payload while
render_chart_requery takes a single `request` model keyed by `identifier`,
so every drill-down and brush-to-zoom failed validation. No test caught it:
the bridge tests cover the bridge's own plumbing, and nothing compared the
payload the widget builds against the schema the server publishes.

Adds a two-sided contract around one artifact — the tool's inputSchema,
checked in under the widget's __fixtures__. TypeScript validates the
payloads App.tsx builds against it (including negative cases for the flat
form, a chart_id-keyed request, and the removed group_by); Python asserts
the live schema still equals it. Drift on either side now fails a test.

Verified falsifiable: simulating an identifier -> chart_id rename fails 5
of the TypeScript cases, and restoring the fixture makes them pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Repo lint bans the stdlib json import (TID251).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hosts cache the widget bundle keyed by its resource URI, so a corrected
bundle served under an unchanged URI is never fetched. The marker-strip
and re-query fixes in 2d4fb4c are invisible for exactly that reason.

Bump v2 -> v3 in both constants that declare it (the resource module and
the tool descriptor) and in the docs that quote it.

The registration test pinned the literal "/v2", which would have failed
on every future bump. Assert the URI carries *a* version suffix instead;
the existing equality check between the two constants still guards the
drift that duplication invites.
The versioned ui:// URI was declared independently in the resource that
serves the bundle and in the tool descriptors that point at it. Bumping the
version — the only reliable way to defeat per-conversation host caching —
meant editing two constants, and only an equality assertion in the tests
kept them from drifting apart.

Moved both the URI and the mime type into an import-free
chart/constants.py. The resource imports the FastMCP app, so the tools
cannot import the resource; a neutral module is what lets both share one
declaration without a cycle.

Verified: tool descriptor and registered resource both resolve to the same
constant, no import cycle, 1258 chart + middleware tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Superset `pie`, `donut`, `scatter` and `bubble` charts fell back to the
styled table because the widget only knew line/bar/area/table/big_number.

Registers PieChart + ScatterChart in the tree-shaken ECharts build (+26 KB,
864 KB against the 1.5 MB budget) and adds two builders:

- pie: donut, largest wedge first, long tails collapsed into one "Other"
  wedge, non-positive values dropped (they cannot be a share of a whole),
  and the measure's series id shared with the cartesian series so the
  bar <-> pie switch morphs instead of cutting.
- scatter: first measure on x, second on y, value axes on both sides,
  degrading to the cartesian renderer when a second measure is missing.

Pie and scatter reorder or collapse rows, so both carry the source row
index on each data item; the click handler resolves that back to the row
before drilling or sharing a point with the assistant.

Adds 18 unit tests plus three live-ECharts smoke tests (pie, scatter, and
a bar -> pie -> bar morph) so a missing chart-type registration fails in
the suite rather than in a host.

Co-Authored-By: Claude <noreply@anthropic.com>
The table rendered every row it was given. A Superset result can carry
~1000 rows, so switching to the table view built a 1000-row DOM inside a
420px iframe — slow to mount and unreadable without endless scrolling.

Adds a paged window (25/50/100/250 rows) with a footer showing the
visible range, the page number and prev/next controls. Sorting still runs
across the whole result and then pages, so "sort descending" surfaces the
global maximum rather than reordering the visible page; a re-query, a
re-sort or a page-size change returns to the first page. The footer is
omitted entirely when the result fits on one page.

The paging arithmetic lives in a pure `paginate()` helper (clamps
out-of-range pages instead of rendering an empty table) and the sticky
header, zebra striping, right-aligned numerics and untrusted-marker
stripping are unchanged. Adds 9 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
Two widget controls were broken in a real host.

"Open in Superset" did nothing. `openLink` only fell back to
`window.open` when *not* embedded; inside a host it fired `ui/open-link`,
waited out the full 8s default timeout when the host did not implement
it, then swallowed the rejection. The click had no effect and no error.
Use a short timeout, fall through to a direct open, and return whether
either route worked so the caller can react. When both fail — a
sandboxed iframe with no popup permission — the widget now copies the
URL to the clipboard and says so, rather than silently doing nothing.

Maximize was one-way. It only ever requested 'fullscreen'; nothing
requested 'inline' and the button was not a toggle, so an expanded
widget could not be restored from inside the widget at all. Track the
display mode (following host-initiated changes too), toggle both ways,
restore the previous height on the no-display-mode-support path, and
bind Escape. The button now reflects and announces its state.

Also collapses the duplicated toast set/clear into one helper that
cancels a pending dismissal, so a second toast no longer inherits the
first one's timer.
Adds CSV and PNG export, gated on what the frame can actually do rather
than on what would look good in a toolbar.

Measured first, in headless Chrome 146: loading the built bundle in a
`sandbox="allow-scripts"` iframe under the declared empty CSP gives the
document an opaque origin, and a blob-URL download there throws nothing
and downloads nothing — no downloadWillBegin, no file. A "Download CSV"
button would have been a dead button. So `isDownloadRestricted()` probes
for that origin and the download actions are omitted, with the menu
saying why. Loaded top-level, the same bundle writes a real 375-byte CSV
and a valid 135 KB PNG.

`navigator.clipboard.writeText` also rejects with NotAllowedError in the
sandboxed frame (a cross-origin iframe has no clipboard-write
permission), so "Copy CSV" falls through to execCommand and then to a
selectable panel. Because execCommand can report success without the
clipboard changing, a "Show CSV" action — which cannot silently fail — is
always offered when downloads are blocked. Hosts that support ui/message
also get "Send data to the assistant" (first 100 rows).

The CSV carries the same formula-injection hardening as
superset/utils/csv.py: a leading =, +, -, @ or control character in a
*text* cell is prefixed with a quote (numbers are left alone so they
survive re-import).

Adds 26 tests. README documents the two cases this cannot detect
(allow-same-origin without allow-downloads, and a lying execCommand) and
states plainly that none of it has been run against Claude or ChatGPT.

Co-Authored-By: Claude <noreply@anthropic.com>
The bundle changed substantially since v3 was published — pie/scatter
views, table pagination, the Open-in-Superset fallback, and the maximize
toggle. Hosts cache the widget by resource URI, so a client that already
fetched v3 keeps serving the older bundle.

Also refreshes the docs and module docstrings that quote the URI.
The widget had never been audited. Four gaps mattered:

1. The chart was a bare canvas — nothing for a screen reader to read.
   `describeChart()` now produces a sentence naming the view, the
   measures, the dimension and the span, fed to ECharts' `aria` option
   (which sets role="img" + aria-label on the container, verified) and
   repeated in a role="status" region so switching views is announced.
2. Every chip was its own tab stop, so reaching the export menu meant
   tabbing past every view and metric chip. Chip groups are now a single
   tab stop with arrow/Home/End movement inside them (WAI-ARIA toolbar
   pattern); the tab stop follows focus so Tab leaves from where the user
   is, and arrows move without selecting.
3. Table sorting was a click handler on a <th> — unreachable by
   keyboard. It is a real button inside the header cell now, with
   aria-sort on the cell and a screen-reader caption on the table.
4. There was no focus indicator. One :focus-visible ring covers every
   control, and prefers-reduced-motion collapses the animations.

Escape now dismisses only the top-most layer (menu/panel) instead of
also collapsing a maximized widget.

Verified in headless Chrome inside the sandboxed frame: role="img" with
the description on the chart, exactly one tab stop for the six view
chips, tab order Maximize -> chips -> Export, and arrow keys plus Enter
switching views with a visible 2px focus ring. Adds 12 tests. Not
audited with a real screen reader — the README says so.

Co-Authored-By: Claude <noreply@anthropic.com>
The 'Other' bucket is not a category. Wearing a palette color let it read
as one, and on a high-cardinality dimension it is usually the largest
wedge — so the chart implied a top category that does not exist.

Completes a half-applied edit that computed the greyed wedges but never
fed them to the series, which failed tsc (TS6133) and broke the build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It read "The values are correct but their order and shape may differ". That is
false, and it is the one direction that matters, because it reassures a reader
out of checking.

Chart 113 is named "Monthly Revenue Trend". Its dates come back as
2003-11-12, 2003-10-17, 2005-02-08 — individual days, not month boundaries
under any grain. The time grain was never applied, so each row is one day's
revenue presented as a month's total: the magnitudes are wrong, not the
sequence. A pivot table collapsing to a single total is the absence of the
breakdown that is the chart's entire content, and a mixed chart missing its
second series is absent data, not reordered data.

The scope of what Superset's buildQuery contributes is therefore wider than
sorting: it carries the time grain, the groupby/series columns, the pivot
structure and the second query for mixed charts. The notice now says both the
values and their arrangement may differ, and names the failure concretely — a
monthly chart returning days, a pivot returning one total — so it reads as a
reason to check rather than a formality.

Same defect class as every other bug on this branch: asserting an outcome that
was never verified. This was the most consequential instance, because the
assertion was the thing a reader would trust instead of comparing.
Merged rather than rebased. The branch carries 53 commits and was 384 behind
master; rebasing replayed our own commits against intermediate states that no
longer exist, producing conflicts between successive versions of files master
has never seen (chart_viewer/ does not exist on master at all). Merging
resolves once, against the final tree that is actually tested.

Conflicts, all resolved as unions of genuinely additive changes:

- middleware.py: master added error sanitisation and the MCP_ERROR_HOOK
  last-resort capture; we added the schema-shaped error for keep-list tools.
  The same `return ToolResult(...)` consumes both — master's `error_text` and
  our `tool_name` — so both are kept.
- test_middleware.py: master added error-handler test classes, we added the
  structured-content keep-list ones. Import line takes master's (its ToolError
  import subsumes ours) plus our two extras.

Also drops files master deleted (superset/viz.py, pooled_screenshot.py,
test_viz_query_obj.py, preset-chart-deckgl/utils/explore.ts). The branch
"modified" all four, but every change was a file-mode bit and no content, so
there was nothing to preserve.

Which exposed the larger problem: 904 files carried an accidental
100644 -> 100755 mode change with zero content difference, swept in by
`git add -A` over a working tree something had chmod'd. Restored to master's
modes. The diff against master goes from 973 files to 69 — the 69 being the
actual change.
@dosubot dosubot Bot added change:backend Requires changing the backend change:frontend Requires changing the frontend viz:charts Namespace | Anything related to viz types labels Aug 24, 2026
@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped - Large PR

Bito didn't auto-review this change because the pull request exceeded the line limit. No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.

@github-actions github-actions Bot added the github_actions Pull requests that update GitHub Actions code label Aug 24, 2026
@aminghadersohi
aminghadersohi marked this pull request as draft August 24, 2026 18:19
@netlify

netlify Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit f82efec
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a8df9815203d200083c27dd
😎 Deploy Preview https://deploy-preview-43483--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

() => paginate(sorted, page, pageSize),
[sorted, page, pageSize],
);
const multiPage = slice.total > PAGE_SIZE_OPTIONS[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: multiPage is determined against the smallest available page size rather than the selected pageSize. When a user selects 50, 100, or 250 rows and the result contains between 26 and that selected size, the table displays pagination controls even though slice.pageCount is one and there is no next page. Compare the total against the active page size instead. [incorrect condition logic]

Severity Level: Minor 🧹
- ⚠️ Table shows unnecessary pagination controls.
- ⚠️ Single-page results display misleading page navigation.
- ⚠️ Affects results with 26–250 rows after larger-page selection.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/resources/chart_viewer/src/components/DataTable.tsx
**Line:** 111:111
**Comment:**
	*Incorrect Condition Logic: `multiPage` is determined against the smallest available page size rather than the selected `pageSize`. When a user selects 50, 100, or 250 rows and the result contains between 26 and that selected size, the table displays pagination controls even though `slice.pageCount` is one and there is no next page. Compare the total against the active page size instead.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +299 to +307
with event_logger.log_context(action="mcp.render_dashboard.layout"):
layout = ModelGetInfoCore(
dao_class=DashboardDAO,
output_schema=DashboardLayout,
error_schema=DashboardError,
serializer=dashboard_layout_serializer,
supports_slug=True,
logger=logger,
).run_tool(request.identifier)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The dashboard layout lookup does not handle exceptions from ModelGetInfoCore.run_tool. Unlike the existing get_dashboard_layout tool, which converts lookup and serialization failures into a structured error response, any database or serializer exception here escapes the render tool and causes the entire dashboard render to fail instead of returning a ChartError or per-dashboard error. [error handling]

Severity Level: Major ⚠️
- ❌ Dashboard widget rendering fails on layout lookup errors.
- ⚠️ Clients receive no structured `ChartError` response.
- ⚠️ A single layout/serialization failure prevents dashboard composition.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/tool/render_chart.py
**Line:** 299:307
**Comment:**
	*Error Handling: The dashboard layout lookup does not handle exceptions from `ModelGetInfoCore.run_tool`. Unlike the existing `get_dashboard_layout` tool, which converts lookup and serialization failures into a structured error response, any database or serializer exception here escapes the render tool and causes the entire dashboard render to fail instead of returning a `ChartError` or per-dashboard error.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +140 to +145
const [activeTab, setActiveTab] = useState<string | null>(() => {
if (!tabs.length) return null;
const requested = render.active_tab_id;
if (requested && tabs.some((t) => t.id === requested)) return requested;
return (tabs.find((t) => countFor(t.id) > 0) ?? tabs[0]).id;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The selected tab is initialized only once, so when the same widget receives a subsequent dashboard result with a different active_tab_id, changed tab contents, or a different tab list, activeTab remains stale and can display the wrong tab or the “not included” placeholder. Synchronize the selection when render changes, while preserving user tab selections where appropriate. [stale reference]

Severity Level: Major ⚠️
- ⚠️ Requery results can display the wrong dashboard tab.
- ⚠️ Removed tabs can leave the widget showing an empty placeholder.
- ⚠️ User-visible dashboard state diverges from the latest tool result.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/resources/chart_viewer/src/components/DashboardGrid.tsx
**Line:** 140:145
**Comment:**
	*Stale Reference: The selected tab is initialized only once, so when the same widget receives a subsequent dashboard result with a different `active_tab_id`, changed tab contents, or a different tab list, `activeTab` remains stale and can display the wrong tab or the “not included” placeholder. Synchronize the selection when `render` changes, while preserving user tab selections where appropriate.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +147 to +149
const cells = activeTab
? render.cells.filter((c) => c.tab_id === activeTab)
: render.cells;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When a dashboard has tabs, every cell is filtered by c.tab_id === activeTab. However, the dashboard contract explicitly allows tab_id to be null for charts that are not inside a tab. Such cells are silently omitted from every tab despite the component's guarantee that no leaf is dropped. Include unassigned cells in the appropriate dashboard view or render them separately instead of filtering them out. [incomplete implementation]

Severity Level: Major ⚠️
- ❌ Unassigned dashboard charts are silently omitted.
- ⚠️ Composite renders do not satisfy their no-dropped-cells contract.
- ⚠️ Users may mistake an incomplete dashboard for a complete one.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/resources/chart_viewer/src/components/DashboardGrid.tsx
**Line:** 147:149
**Comment:**
	*Incomplete Implementation: When a dashboard has tabs, every cell is filtered by `c.tab_id === activeTab`. However, the dashboard contract explicitly allows `tab_id` to be null for charts that are not inside a tab. Such cells are silently omitted from every tab despite the component's guarantee that no leaf is dropped. Include unassigned cells in the appropriate dashboard view or render them separately instead of filtering them out.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +337 to +350
const next = (await bridge.callTool(REQUERY_TOOL_NAME, {
request: {
identifier: data.chart_id,
...args,
},
})) as ChartData;
if (next && Array.isArray(next.columns)) {
setData(next);
setActiveMetrics(classifyColumns(next).numeric.map((c) => c.name));
setDrill({
active: true,
label: stripUntrustedMarkers(drillLabel),
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Multiple clicks or brush actions can start overlapping re-queries, and each response unconditionally calls setData. If an earlier request finishes after a newer interaction, it overwrites the newer result and drill label with stale data. Track a request generation or cancel/ignore superseded requests before applying the response. [race condition]

Severity Level: Major ⚠️
- ⚠️ Rapid point clicks can display stale drill-down data.
- ⚠️ Brush zoom results can be overwritten by older clicks.
- ⚠️ Drill labels may not match displayed data.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/resources/chart_viewer/src/App.tsx
**Line:** 337:350
**Comment:**
	*Race Condition: Multiple clicks or brush actions can start overlapping re-queries, and each response unconditionally calls `setData`. If an earlier request finishes after a newer interaction, it overwrites the newer result and drill label with stale data. Track a request generation or cancel/ignore superseded requests before applying the response.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +362 to +369
if (this.isEmbedded && this.capabilities.canOpenLinks) {
if (await this.requestOk('ui/open-link', { url }, timeoutMs)) return true;
}
// Synchronously, inside the click's user gesture: awaiting anything first
// spends transient activation and gets the popup blocked.
try {
if (typeof window !== 'undefined' && window.open(url, '_blank', 'noopener'))
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When the host advertises openLinks but the host request fails or times out, this await completes before window.open is attempted. That consumes the click's transient user activation, so the direct popup fallback is commonly blocked even though it would have worked when invoked synchronously from the click handler. Open a synchronously-created popup before awaiting the host response, or use a separate synchronous fallback path. [logic error]

Severity Level: Minor 🧹
- ⚠️ Explore links fail to open when host handling times out.
- ⚠️ Embedded users fall back to copying URLs manually.
- ⚠️ The failure affects hosts advertising unavailable link support.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/resources/chart_viewer/src/bridge.ts
**Line:** 362:369
**Comment:**
	*Logic Error: When the host advertises `openLinks` but the host request fails or times out, this `await` completes before `window.open` is attempted. That consumes the click's transient user activation, so the direct popup fallback is commonly blocked even though it would have worked when invoked synchronously from the click handler. Open a synchronously-created popup before awaiting the host response, or use a separate synchronous fallback path.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +596 to +598
private onMessage = (event: MessageEvent): void => {
const msg = event.data as JsonRpcMessage | undefined;
if (!msg || msg.jsonrpc !== '2.0') return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The message handler accepts responses from any postMessage sender without checking event.source or the expected host origin. Because request IDs are predictable, another frame or window can spoof a response, inject tool-result data, or alter capability and link-operation results. Restrict responses to the initialized parent window and validate the allowed origin where available. [security]

Severity Level: Major ⚠️
- ⚠️ Untrusted frames can inject chart-result notifications.
- ⚠️ Spoofed responses can alter displayed chart integrity.
- ⚠️ Capability responses can influence link or tool operations.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/resources/chart_viewer/src/bridge.ts
**Line:** 596:598
**Comment:**
	*Security: The message handler accepts responses from any `postMessage` sender without checking `event.source` or the expected host origin. Because request IDs are predictable, another frame or window can spoof a response, inject tool-result data, or alter capability and link-operation results. Restrict responses to the initialized parent window and validate the allowed origin where available.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +643 to +658
tags=(
[
TagInfo.model_validate(tag, from_attributes=True)
for tag in getattr(chart, "tags", [])
]
if getattr(chart, "tags", None)
else []
),
editors=(
[
info
for editor in getattr(chart, "editors", [])
if (info := serialize_subject_object(editor)) is not None
]
if getattr(chart, "editors", None)
else []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: list_charts serializes each chart through this function without eager-loading tags or editors. Accessing both relationships for every item lazily issues additional queries per chart, turning a multi-chart listing into an N+1 query workload and potentially triggering detached-instance failures after the DAO session is closed. Either eager-load these relationships in the list query or avoid loading them when they were not requested. [performance]

Severity Level: Major ⚠️
- ⚠️ list_charts performs extra queries per returned chart.
- ⚠️ Large chart pages increase database latency.
- ❌ Detached objects can fail chart-list serialization.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/schemas.py
**Line:** 643:658
**Comment:**
	*Performance: `list_charts` serializes each chart through this function without eager-loading `tags` or `editors`. Accessing both relationships for every item lazily issues additional queries per chart, turning a multi-chart listing into an N+1 query workload and potentially triggering detached-instance failures after the DAO session is closed. Either eager-load these relationships in the list query or avoid loading them when they were not requested.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +589 to +591
# Set only when we fall back to reconstructing the query from
# form_data, which drops Superset's post_processing stage.
fidelity_warning: str | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The warning is initialized but only populated for the saved-chart path that falls back because query_context is absent. The using_unsaved_state path immediately builds a query from cached form_data, which has the same loss of Superset's saved query/post-processing pipeline, but leaves fidelity_warning as None; renderers therefore present potentially rearranged or numerically different results without the warning this change adds. Set the warning whenever the query is reconstructed from form data, including the unsaved-state branch. [logic error]

Severity Level: Major ⚠️
- ⚠️ Unsaved chart renders omit query-fidelity warnings.
- ⚠️ MCP widgets may present rearranged chart data without disclosure.
- ⚠️ Dashboard cells reuse the same unwarned core path.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/chart/tool/get_chart_data.py
**Line:** 589:591
**Comment:**
	*Logic Error: The warning is initialized but only populated for the saved-chart path that falls back because `query_context` is absent. The `using_unsaved_state` path immediately builds a query from cached `form_data`, which has the same loss of Superset's saved query/post-processing pipeline, but leaves `fidelity_warning` as `None`; renderers therefore present potentially rearranged or numerically different results without the warning this change adds. Set the warning whenever the query is reconstructed from form data, including the unsaved-state branch.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +705 to +707
DEFAULT_STRUCTURED_CONTENT_KEEP_TOOLS: frozenset[str] = frozenset(
{"render_chart", "render_chart_requery"}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The fallback keep-list omits render_dashboard, even though the dashboard tool is registered as a structured-content widget and the configured keep-list includes it. Whenever this middleware runs without a Flask application context, _keep_tools() returns this default and strips render_dashboard's output_schema and structured_content, preventing dashboard widgets from rendering. Include all structured-content widget tools in the default set. [api mismatch]

Severity Level: Major ⚠️
- ❌ Dashboard MCP Apps lose structured widget rendering.
-`render_dashboard` output schema is removed on fallback.
- ⚠️ Dashboard results degrade to non-interactive text responses.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/middleware.py
**Line:** 705:707
**Comment:**
	*Api Mismatch: The fallback keep-list omits `render_dashboard`, even though the dashboard tool is registered as a structured-content widget and the configured keep-list includes it. Whenever this middleware runs without a Flask application context, `_keep_tools()` returns this default and strips `render_dashboard`'s `output_schema` and `structured_content`, preventing dashboard widgets from rendering. Include all structured-content widget tools in the default set.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +720 to +723
payload = MCPBaseError(
error_type=type(exc).__name__,
message=str(exc),
).model_dump(mode="json")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The structured error path bypasses _sanitize_error_for_logging and places str(exc) directly into structured content. Exceptions from database or authentication code can contain SQL fragments, connection details, or other sensitive internals, so keep-list tools expose data that the preceding text error path intentionally sanitizes. Sanitize the message before constructing MCPBaseError. [security]

Severity Level: Major ⚠️
- ⚠️ Keep-list error responses can expose internal exception text.
- ⚠️ MCP clients may receive SQL or connection details.
- ⚠️ Dashboard and chart widget failures share this path.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/middleware.py
**Line:** 720:723
**Comment:**
	*Security: The structured error path bypasses `_sanitize_error_for_logging` and places `str(exc)` directly into structured content. Exceptions from database or authentication code can contain SQL fragments, connection details, or other sensitive internals, so keep-list tools expose data that the preceding text error path intentionally sanitizes. Sanitize the message before constructing `MCPBaseError`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Every tool call failed with `name 'has_app_context' is not defined`. The merge
took master's `from flask import g` — master had removed its own
has_app_context guards, replacing them with try/except around get_user_id —
while StructuredContentStripperMiddleware._keep_tools(), which is ours and
still needs the guard, kept using it. The file compiles, so nothing caught it:
the NameError only fires on the path every tool call takes.

Audited the rest of the merge rather than assuming this was the only loss. For
every file the branch touched, checked that every line the branch ADDED
relative to the merge base is still present: zero missing, across all files.
The line-count drops elsewhere are master's own refactors, not dropped work.

Also fixes a divergence this surfaced. DEFAULT_STRUCTURED_CONTENT_KEEP_TOOLS
listed render_chart and render_chart_requery; MCP_STRUCTURED_CONTENT_KEEP_TOOLS
also listed render_dashboard. The config wins wherever a Flask app context
exists, so a running server was fine and only the contextless fallback differed
— a divergence reachable only in the environment nobody runs. A test now pins
the two together.
f82efec committed middleware.py and test_middleware.py as 100755. The
worktree's filesystem carries ACLs that do not round-trip mode bits, so the
executable flag was picked up rather than intended. core.fileMode is now false
here, which stops git seeing those flips at all.

No mode differences remain between this branch and master.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend change:frontend Requires changing the frontend github_actions Pull requests that update GitHub Actions code review:draft size/XXL viz:charts Namespace | Anything related to viz types

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants