@bonnard/mcp-charts adds a visualize tool plus an embedded chart widget to any MCP server. The agent writes SQL, your database returns the rows, and the result renders as an interactive chart inside the host (Claude, ChatGPT, and other MCP Apps clients). You write no frontend code.
Pre-1.0: the API may change before a 1.0 release.
Install
npm install @bonnard/mcp-charts
Quickstart
Call addCharts on your existing MCP server and give it a read-only query callback:
import { addCharts } from "@bonnard/mcp-charts";
import { postgresRunSql } from "@bonnard/mcp-charts/postgres";
addCharts(server, {
runSql: postgresRunSql(pool), // maps pg column types to chart field kinds
discovery: { toolName: "explore_schema" }, // tell the agent to discover tables first
});
That registers a visualize tool and a ui://bonnard/chart widget resource. The agent calls it
with SQL; the rows render as a chart in the host, with a text fallback for non-widget clients.
Adapters ship for postgres, bigquery, snowflake, databricks, and duckdb. For an engine
without one, build typed ChartData with buildChartData (or declare fields yourself) so
numeric/temporal columns aren't inferred from raw driver values.
How it works
- The agent calls
visualizewith a query (SQL / semantic query / chart params). - Your callback runs it against your warehouse / ORM and returns rows.
- Bonnard infers the chart encoding from the typed result and renders it as an MCP App
(a sandboxed
ui://widget) in Claude or ChatGPT. One widget, both hosts.
Why
- Grounded in real data. Charts render the rows your query returned, not numbers the model typed into a tool call. No hallucinated figures.
- A few lines, no frontend. One function, one widget that works across every MCP Apps host. You don't maintain per-client rendering code.
- Interactive, not static images. Tooltips, legends, and axis formatting, native to the client.
Chart types
Eight types, chosen automatically from the shape of your data or set explicitly: bar, line, area, pie, scatter, funnel, waterfall, table. Plus bar variants (stacked, grouped, 100% stacked, horizontal), dual-axis combos, bubble sizing, and reference lines. See the chart types reference.
Named views (charts and dashboards)
When you author the queries instead of letting the agent write SQL, register named views with
addViews. It registers two tools over a registry: explore_views lists what's available (id, title,
description, params) and render_view renders one by view_id, binding the result to the widget.
Each ViewDef returns a single ChartSpec (via chart(rows, opts)) or a composed DashboardSpec,
and can declare zod params that render_view validates per view.
import { addViews, chart, chartCell } from "@bonnard/mcp-charts";
import { z } from "zod";
addViews(server, {
views: [
{
id: "revenue_trend",
title: "Revenue trend",
description: "Monthly revenue",
kind: "chart",
render: () => chart(monthlyRows, { chartType: "line", title: "Revenue by month" }),
},
{
id: "sales_overview",
title: "Sales overview",
description: "KPIs + charts, optionally per region",
kind: "dashboard",
params: { region: z.enum(["EU", "US", "APAC"]).optional() },
render: ({ region }) => ({
title: "Sales overview",
columns: 2,
items: [
{ type: "kpi", label: "Revenue", value: 336800, format: "currency", currency: "USD", delta: 61800 },
chartCell(monthlyRows, { chartType: "line", title: "Revenue by month", span: 2, id: "revenue_by_month" }),
chartCell(regionRows, { chartType: "bar", title: "Revenue by region", id: "revenue_by_region" }),
],
}),
},
],
});
A dashboard-kind view returns a DashboardSpec (a grid of chart cells, KPI tiles, and text blocks);
chartCell builds a cell from raw rows. Give a cell an id and the agent can re-render it alone by
passing render_view an item_id (the ids appear in the dashboard summary). See examples/dashboard
for a six-view server, the views guide, and the
dashboards reference for the DashboardSpec shape.
Warehouse adapters
Skip writing runSql by hand. Each adapter wraps your driver and maps native column types to chart roles (dimension, measure, time):
import { postgresRunSql } from "@bonnard/mcp-charts/postgres";
addCharts(server, { runSql: postgresRunSql(pool) });
Bundled for Postgres, BigQuery, Snowflake, Databricks, and DuckDB. Each driver is an optional peer dependency, installed only if you import its subpath. See warehouse adapters.
Connecting a database
When you build views from live, unseen data, prefer a typed source. An adapter (or buildChartData) hands resolve() a ChartData with column types from your driver, so the encoding is decided from types, not sniffed from a sample:
import { chart, buildChartData } from "@bonnard/mcp-charts";
// A typed ChartData is accepted anywhere raw rows are — same one line, driver types instead of a sniff.
const data = buildChartData({ rows, columns, mapKind }); // or an adapter's runSql(...)
const spec = chart(data, { chartType: "line" });
chart(rows, opts) and chart(chartData, opts) are the same call: pass raw Record<string, unknown>[] to sniff, or a { rows, fields?, encode?, notes? } to trust declared types.
Numbers must arrive as numbers. Some drivers stringify decimals/bigints (revenue: "1234"). Inference recovers all-numeric-string columns to a measure and adds an advisory note, but the robust fix is to declare the column's kind (via fields or an adapter) or cast in SQL. fields and encode are trusted verbatim: a wrong declaration is honored, so a mistyped measure yields a blank series (with a note).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank chart, "No measure column to plot" note | value column arrived as strings, or a fields/encode declaration left no measure |
return numbers (not numeric strings), or declare the column kind: "number" |
| Bar/line over "dates" isn't sorted; "looks like non-ISO dates" note | dates are strings in a loose format (01/15/2026, Jan 2025) |
return ISO dates (YYYY-MM-DD) so the axis sorts chronologically |
| Multi-slice pie / odd funnel, with a precondition note | forced a chart type whose shape needs a category + a measure | supply one dimension + one measure, or drop the forced chartType |
| Ignored encode column note | encode.x/encode.y names a column not in the result |
fix the column name (the note lists the available columns) |
To catch these before a host renders, assert the encoding in a test with explain():
import { explain } from "@bonnard/mcp-charts";
expect(explain(sampleRows, { chartType: "bar" }).series.length).toBeGreaterThan(0);
// or fail loud on a bad encoding:
explain(sampleRows, { chartType: "bar", strict: true }); // throws on zero series / ignored encode
See the connecting-a-database guide for the full DB-correctness story.
Security
visualize executes agent-written SQL against your database. Treat it as untrusted input: connect runSql to a read-only, least-privilege role scoped to the data you want exposed. Your database permissions are the security boundary; the SDK does not sandbox queries. See Security.
Links
- Website: https://bonnard.dev
- Docs: https://docs.bonnard.dev/mcp-charts/getting-started
- Issues: https://github.com/bonnard-data/mcp-charts/issues
- Contact: [email protected]
Repo layout
This is a pnpm monorepo.
packages/core— the SDK (@bonnard/mcp-charts): thevisualizetool, theresolve()encoding logic, theChartDatacontract, and theui://widget resource.packages/widget— the in-iframe renderer (ECharts), bundled to a single HTML file and embedded into core.examples— runnable example servers.
Development
pnpm install
pnpm build # widget -> core (embeds the widget)
pnpm check # format, lint, typecheck
pnpm test
License
MIT
No comments yet
Be the first to share your take.