viz-mcp
An MCP server that renders charts and diagrams as live, interactive UI inside thechat — not as image URLs. Built on the MCP Apps extension (SEP-1865).
npm install
npm run build
npm start # http://localhost:3000/mcp
What it exposes
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 entryrenders full-width exactly like a single chart always has; 2+ entries rendertogether as one fixed-cell grid image (2 or 3 columns depending on count, upto 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 entriesin the model's tool list and burns context on every request; a discriminatedkind field covers the same ground and gives the host exactly two templates toprefetch and security-review.
How the live rendering actually works
The host streams tool arguments to the view before the tool executes, viaui/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 orhalf-written, so both views treat the payload as untrusted and drop anythingmalformed rather than throwing — the next partial supersedes it.
Charts and diagrams stream differently
This is the part worth understanding before modifying either view.
Charts append. ECharts setOption merges by default, so re-issuing it with alonger data array adds points and animates the transition. Nothing already drawnmoves. The chart view simply redraws on every partial.
Diagrams reflow. Graph layout is global — add one node and the enginere-solves the whole graph, so every existing node jumps. Running layout on eachpartial produces a diagram that thrashes violently, which reads as broken ratherthan live.
The graph view avoids this entirely. While arguments stream, nodes accumulate ina wrapping pill row — deliberately not a graph. Once the complete argument setarrives, layout runs once against the full node and edge set, and nodes arerevealed in sequence along positions that were already solved. A node that hasappeared never moves again.
Reserve true incremental re-layout for graphs that grow from a live data sourcewhere the final shape genuinely isn't knowable up front. Cytoscape'slayout.run({ animate: true }) tweens between solutions if you need it.
Context budget
Tool results carry a one-line summary in content and the full dataset instructuredContent. Only content enters the model's context. Skip this splitand a thousand-point chart costs you a thousand points of context on everysubsequent turn.
Host requirements
This server assumes a host that implements the MCP Apps extension. Roughlytwo-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 pathas a direct tool call
@mcp-ui/client provides AppRenderer, which covers most of this.
CSP
The views load ECharts, Cytoscape, and dagre from jsDelivr. That domain isdeclared in RESOURCE_DOMAINS in src/server.ts and surfaces to the host as_meta.ui.csp.resourceDomains. Anything not on that list fails silently insidethe 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 theCSP entry, at the cost of a larger resource payload. Worth doing if your hostsrun in restricted network environments.
Transport
Stateless Streamable HTTP: a fresh server and transport per POST, no sessionstate, no sticky routing required. This matches the direction of the 2026-07-28revision, which removes protocol-level sessions and the GET stream endpoint.
If you target that revision, note that clients must also send Mcp-Method andMcp-Name headers on POSTs so gateways can route without inspecting the body.
Extending
Add a chart type: extend the kind enum in chartSpec, then handle it intoEchartsSeries in chart-view.html.
Change the multi-chart grid: column count and per-cell size are computedindependently in two places that must stay in sync — chartCanvasSize inserver.ts (sizes the render viewport) and gridColumns/.chart-cell-mountin chart-view.html (lays out the actual grid).
Add interactivity: the views can call back into the server withapp.callServerTool({ name, arguments }). Register the target tool with_meta.ui.visibility: ["app"] to keep it out of the model's tool list — usefulfor drill-downs and filters that shouldn't consume model attention.