Skip to main content
Glama
jmilinovich

flint-chart-connector

by jmilinovich
README.md
# Flint charts for Claude and Codex

[Flint](https://microsoft.github.io/flint-chart/) is Microsoft Research's chart compiler.
You hand it a compact semantic spec (chart type, which field goes on which channel, and
what each field *means*), and it derives the scales, axes, tick formats, colour schemes,
label rotation, legends and layout, emitting native Vega-Lite, ECharts or Chart.js. It
works well with models, because semantic types are much easier to infer correctly than a
full chart config.

This repo packages Flint as a remote MCP server you deploy yourself, tuned for chat hosts.
It's verified working in Claude (web, desktop, mobile, Code) and OpenAI Codex.

There is no shared instance to join, so you'll want to deploy your own. It takes about a
minute and sits comfortably inside the free tier.

## Deploy it

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fjmilinovich%2Fflint-chart-connector)

Or from a clone:

```bash
git clone https://github.com/jmilinovich/flint-chart-connector
cd flint-chart-connector
npm install
npx vercel deploy --prod
```

Either way you end up with a URL. Your connector endpoint is that URL plus `/mcp`:

```text
https://<your-deployment>.vercel.app/mcp
```

Open the root URL in a browser and the landing page shows you that endpoint, already
filled in, with a copy button.

Nothing here is Vercel-specific except `vercel.json`. It's a plain Node HTTP handler
(`src/http.js`), so any host that runs Node 20+ will do. `npm run dev` serves it on
`localhost:8787`.

## Connect it

**Claude web, desktop, mobile.** Settings → Connectors → Add custom connector, paste the
`/mcp` URL. No authentication. Claude connects from Anthropic's cloud rather than from your
machine, which is why the endpoint has to be publicly reachable, and why this is hosted
rather than run locally.

**Claude Code.**

```bash
claude mcp add --transport http flint https://<your-deployment>.vercel.app/mcp
```

**Codex.**

```bash
codex mcp add flint --url https://<your-deployment>.vercel.app/mcp
```

or in `~/.codex/config.toml`:

```toml
[mcp_servers.flint]
url = "https://<your-deployment>.vercel.app/mcp"
```

Then ask for a chart: *"chart my last six months of revenue by segment."*

### Or run it locally instead

For stdio hosts, meaning Claude Code and Claude Desktop, Microsoft's own server runs
locally with no deployment at all:

```bash
claude mcp add flint -- npx -y flint-chart-mcp
```

That's the better option if local stdio is all you need. Deploy this one when you want a
URL, for Claude web and mobile or for Codex, or when you want the tuned surface described
below.

## Tools

| Tool | Use |
| --- | --- |
| `render_chart` | Show a chart. Live and editable in Claude, a PNG everywhere else. The default. |
| `compile_chart` | Backend-native Vega-Lite / ECharts / Chart.js JSON, for artifacts and embedding. |
| `list_chart_types` | Chart types with the channels and properties each accepts. |

## What's different from upstream

The charts are unmodified. This calls `flint-chart` and `flint-chart-mcp/render` straight
off npm, and the interactive UI is upstream's own prebuilt `flint-app.html`. What changes
is the surface presented to the host.

**One `render_chart` that is both interactive and static.** Upstream ships five tools,
including a separate `create_chart_view` for the interactive path, and tells the model to
*prefer* it. On a host without MCP Apps that means announcing a chart and then displaying
nothing. Here a single tool is always registered as an MCP App and always returns a
rendered PNG. Claude draws the live widget with Flint's editing panel, while Codex and
everything else get the image, so neither ends up without a chart.

**The catalogs live in the schema.** Flint's 44 semantic types and its chart-type list are
declared as enums, read out of the installed package at import time so they cannot drift.
This matters because an unrecognised semantic type is ignored silently, with no warning,
and you quietly lose behaviour. `{ month: "YearMonth" }` gives you a temporal axis, while a
plausible-looking `"Month-Year"` gives you unordered categories and says nothing. Both
hosts preserve the enums: Claude as JSON Schema, Codex as TypeScript string-literal unions.

**Guidance lives in the server instructions**, not in an MCP resource and not only in
per-parameter descriptions. Neither host auto-reads resources, and Codex drops parameter
descriptions entirely (see the gotchas below). Upstream ships a 567-line authoring skill as
a resource that a chat host will never load.

**No local file reads.** This is a remote server, so its disk is not yours. Data is inline
rows only.

`/view` is a static page that renders a spec client-side with tooltips and PNG/SVG export,
and `render_chart` links to it. The spec is raw-deflated into the URL fragment, which
browsers never send to a server.

## Layout

```
api/mcp.js     Vercel function → src/http.js
src/http.js    Stateless Streamable HTTP transport
src/server.js  Tools, schemas, instructions
src/catalog.js Semantic types + chart types, read from the installed flint-chart
src/app-ui.js  Locates upstream's prebuilt MCP App UI bundle
src/share.js   Spec → URL-fragment packing for the viewer
public/        Landing page, viewer, vendored Vega bundles
```

## Development

```bash
npm install
npm run dev      # http://localhost:8787/mcp
npm test         # 19 tests, no network
npm run vendor   # refresh public/vendor after bumping flint-chart
```

## Gotchas

**Never gate MCP App registration on client capability over a stateless transport.**
Claude declares `io.modelcontextprotocol/ui` only in `initialize` and never repeats it, so
on a stateless server every later request looks incapable:

```
initialize    uiDeclared=true
tools/list    uiDeclared=false   <-- registers the plain tool
tools/call    uiDeclared=false
```

The app tool then never gets advertised and the widget never renders, however well the host
supports it. Register unconditionally instead, since the `_meta` is inert on hosts that
ignore it.

**Always return the image too.** Skipping the raster on app hosts to avoid a duplicate view
sounds efficient right until the widget doesn't render, at which point the answer has a link
and no chart. Claude renders the widget and ignores the image content, so the duplicate
never materialises anyway, and Codex has no widget at all and needs the image.

**Codex rewrites your schema as a TypeScript signature.** Enums survive as string-literal
unions, which is what you want. Per-parameter `description` text does not survive at all,
though the server `instructions` and the tool `description` both do. Any guidance that has
to reach the model belongs in the instructions rather than in a parameter description.

**`GET /mcp` must not return JSON.** A GET carrying `Accept: text/event-stream` is a client
opening the optional server-to-client stream. A stateless server has nothing to push and
should answer `405`. Returning `200 application/json` instead can put the official MCP
TypeScript SDK client into a reconnect loop. Plain GETs still get a health blob.

**`framework: null` in `vercel.json` is load-bearing.** Left to auto-detect, Vercel reads a
bare `package.json` as its Node server preset, bundles the repo root into one function, and
every route including static files 500s into a lambda with no entrypoint (`Invalid export
found`).

**`includeFiles` is load-bearing too.** The render path needs `@resvg/resvg-js` for
rasterising, `@napi-rs/canvas` for text metrics so label widths match the browser, and
`flint-chart-mcp/assets/fonts` for Liberation Sans. The font directory is resolved at
runtime by walking up from the module, which dependency tracing cannot see. Without it you
get charts with mismeasured labels.

## Locking it down

The endpoint is open by default, which is what keeps setup to one paste. Anyone who learns
your URL can render charts on your account. Set `FLINT_MCP_SECRET` and the URL becomes the
credential:

```bash
vercel env add FLINT_MCP_SECRET production
```

Then use `https://…/mcp?k=<secret>` as the connector URL. `Authorization: Bearer <secret>`
works too, which suits Claude Code and Codex. The query form exists because some connector
dialogs accept nothing but a URL.

## Data handling

Rows are compiled and rasterised in memory and returned as an image. Nothing is written to
disk or a database, and the server reads no local files and fetches no URLs. The viewer is
a static page whose spec rides in the URL fragment.

---

Flint is MIT-licensed, by Microsoft Research with IDEAS Lab, Renmin University of China.
This connector is an independent wrapper and is not affiliated with Microsoft.