Render charts with Chart.js

In this tutorial you'll run Chart.js inside an mcp-v8 isolate and get a chart out of it — first as SVG, then as a PNG file rasterized and written entirely from JavaScript.

This is the Chart.js Using from Node.js example, adapted to a runtime with no native modules. It shows how to work around a library whose usual Node companion is a native addon: keep the pure-JS part, and replace the native part with something that runs in the isolate.

Prerequisites

  • mcp-v8 installed (see Install).
  • curl and jq.
  • The repo checked out, for the example source:
git clone --depth 1 https://github.com/r33drichards/mcp-js
cd mcp-js

The complete script lives at examples/chartjs/chart.js.

Why the upstream example needs adapting

The Chart.js docs draw onto a skia-canvas canvas. That package — like node-canvas — is a native Node addon, and the isolate has no Node ABI to load one with. Importing it fails immediately:

await import("npm:skia-canvas");
// ReferenceError: window is not defined

Chart.js itself is fine. It is pure JavaScript, it imports cleanly with npm: specifiers, and when no window is present it selects its own BasicPlatform — so it never reaches for the DOM. All it needs from a "canvas" is an object with getContext("2d") and pixel dimensions.

So the example supplies the canvas in JavaScript:

Piece Provided by
Canvas2D API → SVG svgcanvas, pure JS
document, XMLSerializer, DOMMatrix, DOMPoint a ~90-line shim at the top of the example
Text metrics approximated per glyph — there is no font engine in the isolate
SVG → PNG (optional) @resvg/resvg-wasm through the WebAssembly API

Step 1 — Start the server with module imports enabled

SVG output needs nothing but external imports:

mcp-v8 --http-port 8080 --allow-external-modules

chart.js and svgcanvas are fetched from esm.sh at import time. The example pins both to exact versions (npm:chart.js@4.5.1, npm:svgcanvas@2.6.0) — always pin npm: specifiers so a run months from now resolves to the same code. To control which packages may be imported at all, add a modules policy — see ES module imports.

Step 2 — Run the example

curl -s http://localhost:8080/api/exec \
  -H 'Content-Type: application/json' \
  -d "$(jq -Rs '{code: .}' examples/chartjs/chart.js)"

That returns an execution_id. The SVG is printed to the console, so read it back from the output endpoint (see Asynchronous execution & output):

curl -s "http://localhost:8080/api/executions/<execution_id>/output" \
  | jq -r '.data' > chart.svg

Open chart.svg and you have the chart from the Chart.js docs — a line across Red, Blue, Yellow, Green, Purple, Orange with values 12, 19, 3, 5, 2, 3.

Step 3 — How the shim fits together

Three small pieces make Chart.js believe it has a canvas. First, a DOM stand-in for svgcanvas to build its SVG tree with:

globalThis.document = {
  createElementNS: (_ns, name) => new El(name),
  createTextNode: (t) => new TextNode(t),
  createElement(name) {
    if (name === "canvas") return { getContext: () => measurer };
    /* ... <span> whose `font:` shorthand is parsed into style longhands ... */
  },
};
globalThis.XMLSerializer = class {
  serializeToString(node) { return node.toXML(); }
};

Second, measureText(). Chart.js sizes axes, ticks, and labels from it, and the isolate has no fonts, so the example approximates advance widths per glyph:

const measurer = {
  font: "10px sans-serif",
  measureText(text) {
    const size = parseFloat(parseFont(this.font).fontSize) || 10;
    return { width: measure(text, size), /* ...ascent/descent... */ };
  },
};

Third, the canvas handed to Chart.js — an object literal, wired to the svgcanvas context:

const ctx = new Context({ width: 400, height: 300 });
const canvas = { width: 400, height: 300, style: {}, getContext: () => ctx };
ctx.canvas = canvas;

const chart = new Chart(canvas, {
  type: "line",
  data: { /* ... */ },
  options: { responsive: false, animation: false, devicePixelRatio: 1 },
});

const svg = ctx.getSerializedSvg(true);
chart.destroy();

responsive: false because there is no DOM to resize against, and animation: false so the chart is fully drawn by the time the SVG is serialized.

Step 4 — Get a PNG instead

Set OUTPUT = "png" at the top of examples/chartjs/chart.js. Rasterizing adds two capabilities: fetch, to pull the resvg WASM binary and a font, and fs, to write the file. Both are denied by default and need policies. The example ships the two minimal ones in examples/chartjs/policies/:

# fetch.rego
package mcp.fetch

default allow = false

allow if {
    input.method == "GET"
    input.url_parsed.host == "unpkg.com"
}
# fs.rego
package mcp.filesystem

default allow = false

allow if {
    startswith(input.path, "/tmp/chart-out/")
}

Start the server with both policies — the url fields must be absolute file:// URLs, so from the repo root:

mkdir -p /tmp/chart-out
mcp-v8 --http-port 8080 --allow-external-modules \
  --policies-json "{\"fetch\":{\"policies\":[{\"url\":\"file://$PWD/examples/chartjs/policies/fetch.rego\"}]},
                    \"filesystem\":{\"policies\":[{\"url\":\"file://$PWD/examples/chartjs/policies/fs.rego\"}]}}"

Submit the script the same way as before. resvg runs as WebAssembly inside the isolate; because it ships without system fonts, the example fetches a TTF and hands it over explicitly:

const { initWasm, Resvg } = await import("npm:@resvg/resvg-wasm@2.6.2");
await initWasm(await (await fetch(".../index_bg.wasm")).arrayBuffer());

const png = new Resvg(svg, {
  font: { fontBuffers: [font], defaultFontFamily: "Roboto", loadSystemFonts: false },
  background: "white",
}).render().asPng();

await fs.writeFile("/tmp/chart-out/output.png", png);

The result — produced start to finish inside the isolate:

Line chart rendered inside the isolate

Things to keep in mind

  • Text metrics are approximate, so label positions can differ from a browser render by a pixel or two. Nothing in the isolate can measure a real font.
  • The first run is slower: the npm modules (and, for PNG, the WASM binary and font) are fetched over the network.
  • The same shim works for other chart types — register the controllers and elements they need (BarController/BarElement, ArcElement, and so on).
  • Anything native stays out of reach. Where a library offers a WASM build (as resvg does), that is the way in.

For the full source, see examples/chartjs/chart.js and examples/chartjs/README.md.

See also