An MCP server that renders charts and diagrams as live, interactive UI inside the chat — not as image URLs. Built on the MCP Apps extension (SEP-1865).
npm install
npm run build
npm start # http://localhost:3000/mcp
Two tools, each bound to its own UI template:
| Tool | UI resource | Renderer |
|---|---|---|
render_chart |
ui://viz/chart-view.html |
ECharts — line, bar, scatter, area |
render_diagram |
ui://viz/graph-view.html |
Cytoscape + dagre — flowcharts, node-link graphs |
render_chart takes a charts array, not a single chart object. One entry
renders full-width exactly like a single chart always has; 2+ entries render
together as one fixed-cell grid image (2 or 3 columns depending on count, up
to 6 charts) in a single call, instead of calling the tool repeatedly.
Two tools rather than one per chart type. A tool-per-type server puts 25 entries
in the model's tool list and burns context on every request; a discriminated
kind field covers the same ground and gives the host exactly two templates to
prefetch and security-review.
The host streams tool arguments to the view before the tool executes, via
ui/notifications/tool-input-partial. That is where the liveness comes from —
not from the tool result, which arrives at the end like any other.
Both views subscribe to three events, in escalating order of authority:
app.ontoolinputpartial = ({ arguments: args }) => { /* incomplete, redraw */ };
app.ontoolinput = ({ arguments: args }) => { /* complete, not yet run */ };
app.ontoolresult = (result) => { /* server-validated, authoritative */ };Partial arguments are incompletely-parsed JSON. Every field may be missing or half-written, so both views treat the payload as untrusted and drop anything malformed rather than throwing — the next partial supersedes it.
This is the part worth understanding before modifying either view.
Charts append. ECharts setOption merges by default, so re-issuing it with a
longer data array adds points and animates the transition. Nothing already drawn
moves. The chart view simply redraws on every partial.
Diagrams reflow. Graph layout is global — add one node and the engine re-solves the whole graph, so every existing node jumps. Running layout on each partial produces a diagram that thrashes violently, which reads as broken rather than live.
The graph view avoids this entirely. While arguments stream, nodes accumulate in a wrapping pill row — deliberately not a graph. Once the complete argument set arrives, layout runs once against the full node and edge set, and nodes are revealed in sequence along positions that were already solved. A node that has appeared never moves again.
Reserve true incremental re-layout for graphs that grow from a live data source
where the final shape genuinely isn't knowable up front. Cytoscape's
layout.run({ animate: true }) tweens between solutions if you need it.
Tool results carry a one-line summary in content and the full dataset in
structuredContent. Only content enters the model's context. Skip this split
and a thousand-point chart costs you a thousand points of context on every
subsequent turn.
This server assumes a host that implements the MCP Apps extension. Roughly two-thirds of the work in a live-UI setup is host-side:
- Fetch
ui://resources at connection time and cache them - Render in a sandboxed iframe with CSP derived from
_meta.ui.csp - Bridge
postMessageto JSON-RPC in both directions - Stream partial tool arguments to the view as the model emits them
- Route UI-initiated
tools/callback through the same consent and audit path as a direct tool call
@mcp-ui/client provides AppRenderer, which covers most of this.
The views load ECharts, Cytoscape, and dagre from jsDelivr. That domain is
declared in RESOURCE_DOMAINS in src/server.ts and surfaces to the host as
_meta.ui.csp.resourceDomains. Anything not on that list fails silently inside
the iframe — if you swap a CDN, update both the <script src> and the list.
Vendoring the libraries into the HTML instead removes the CDN dependency and the CSP entry, at the cost of a larger resource payload. Worth doing if your hosts run in restricted network environments.
Stateless Streamable HTTP: a fresh server and transport per POST, no session state, no sticky routing required. This matches the direction of the 2026-07-28 revision, which removes protocol-level sessions and the GET stream endpoint.
If you target that revision, note that clients must also send Mcp-Method and
Mcp-Name headers on POSTs so gateways can route without inspecting the body.
Add a chart type: extend the kind enum in chartSpec, then handle it in
toEchartsSeries in chart-view.html.
Change the multi-chart grid: column count and per-cell size are computed
independently in two places that must stay in sync — chartCanvasSize in
server.ts (sizes the render viewport) and gridColumns/.chart-cell-mount
in chart-view.html (lays out the actual grid).
Add interactivity: the views can call back into the server with
app.callServerTool({ name, arguments }). Register the target tool with
_meta.ui.visibility: ["app"] to keep it out of the model's tool list — useful
for drill-downs and filters that shouldn't consume model attention.