@bonnard/mcp-charts 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,38 +1,77 @@
1
- # @bonnard/mcp-charts
1
+ <h1 align="center">@bonnard/mcp-charts</h1>
2
2
 
3
- Add agent-ready, interactive charts to your MCP server in a few lines. `addCharts` registers a
4
- `visualize` tool plus an embedded chart widget; your agent writes SQL, you run it, and the result
5
- renders as an interactive chart inside the MCP host (Claude, ChatGPT, and other MCP Apps clients).
3
+ <p align="center">Interactive charts for your MCP server, in a few lines.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@bonnard/mcp-charts"><img src="https://img.shields.io/npm/v/@bonnard/mcp-charts" alt="npm version"></a>
7
+ <a href="https://github.com/bonnard-data/mcp-charts/actions/workflows/ci.yml"><img src="https://github.com/bonnard-data/mcp-charts/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
8
+ <a href="./LICENSE"><img src="https://img.shields.io/npm/l/@bonnard/mcp-charts" alt="MIT license"></a>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <img src="https://raw.githubusercontent.com/bonnard-data/mcp-charts/main/assets/hero-bubble.png" alt="A chart rendered inside Claude from a visualize tool call" width="820">
13
+ </p>
14
+
15
+ `@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.
16
+
17
+ > Pre-1.0: the API may change before a 1.0 release.
18
+
19
+ ## Install
6
20
 
7
21
  ```bash
8
22
  npm install @bonnard/mcp-charts
9
23
  ```
10
24
 
25
+ ## Quickstart
26
+
27
+ Call `addCharts` on your existing MCP server and give it a read-only query callback:
28
+
11
29
  ```ts
12
30
  import { addCharts } from "@bonnard/mcp-charts";
31
+ import { postgresRunSql } from "@bonnard/mcp-charts/postgres";
13
32
 
14
33
  addCharts(server, {
15
- // your read-only query callback return { rows } (and optionally typed `fields`)
16
- runSql: async (sql) => ({ rows: await db.query(sql) }),
17
- discovery: { toolName: "explore_schema" }, // your schema-discovery tool
34
+ runSql: postgresRunSql(pool), // maps pg column types to chart field kinds
35
+ discovery: { toolName: "explore_schema" }, // tell the agent to discover tables first
18
36
  });
19
37
  ```
20
38
 
21
- You own the database connection; the SDK only turns the rows into a chart. Bring your own SQL, or
22
- use a bundled warehouse adapter (BigQuery, Postgres, DuckDB, Snowflake, Databricks):
39
+ 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.
40
+
41
+ ## Why
42
+
43
+ - **Grounded in real data.** Charts render the rows your query returned, not numbers the model typed into a tool call. No hallucinated figures.
44
+ - **A few lines, no frontend.** One function, one widget that works across every MCP Apps host. You don't maintain per-client rendering code.
45
+ - **Interactive, not static images.** Tooltips, legends, and axis formatting, native to the client.
46
+
47
+ <p align="center">
48
+ <img src="https://raw.githubusercontent.com/bonnard-data/mcp-charts/main/assets/interactive-waterfall.png" alt="Interactive waterfall chart with a hover tooltip, rendered in Claude" width="820">
49
+ </p>
50
+
51
+ ## Chart types
52
+
53
+ 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](https://docs.bonnard.dev/mcp-charts/chart-types).
54
+
55
+ ## Warehouse adapters
56
+
57
+ Skip writing `runSql` by hand. Each adapter wraps your driver and maps native column types to chart roles (dimension, measure, time):
23
58
 
24
59
  ```ts
25
60
  import { postgresRunSql } from "@bonnard/mcp-charts/postgres";
26
61
  addCharts(server, { runSql: postgresRunSql(pool) });
27
62
  ```
28
63
 
29
- ## Read-only access
64
+ 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](https://docs.bonnard.dev/mcp-charts/adapters).
65
+
66
+ ## Security
67
+
68
+ `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](https://docs.bonnard.dev/mcp-charts/getting-started#security).
30
69
 
31
- The agent writes the SQL, so run it through a **read-only database role** — that is the security
32
- boundary. The bundled adapters also apply a lightweight SELECT-only check (`assertReadOnlySql`), but
33
- treat that as a guardrail, not a substitute for least-privilege credentials.
70
+ ## Links
34
71
 
35
- Full docs and examples: https://github.com/bonnard-data/mcp-charts
72
+ - Docs: https://docs.bonnard.dev/mcp-charts/getting-started
73
+ - Website: https://bonnard.dev
74
+ - Issues: https://github.com/bonnard-data/mcp-charts/issues
36
75
 
37
76
  ## License
38
77
 
@@ -1,5 +1,5 @@
1
1
  import { TableField, BigQuery } from '@google-cloud/bigquery';
2
- import { C as ChartData } from './types-CHiUU9mj.js';
2
+ import { C as ChartData } from './types-6ALzXWjE.js';
3
3
 
4
4
  /** A BigQuery schema field. Anchored to the client's own `TableField` type so it tracks the driver:
5
5
  * if @google-cloud/bigquery changes the schema shape, this and its consumers fail to compile.
package/dist/bigquery.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  assertReadOnlySql,
3
3
  buildChartData
4
- } from "./chunk-NGXV4I2L.js";
4
+ } from "./chunk-I3HINKHN.js";
5
5
 
6
6
  // src/adapters/bigquery.ts
7
7
  var NUMERIC = /* @__PURE__ */ new Set(["INT64", "INTEGER", "FLOAT64", "FLOAT", "NUMERIC", "BIGNUMERIC"]);
@@ -11,13 +11,55 @@ function sniffTimeGranularity(value) {
11
11
  }
12
12
  return null;
13
13
  }
14
+ var DAY_MS = 864e5;
15
+ function sniffGranularityFromValues(values) {
16
+ const seen = /* @__PURE__ */ new Set();
17
+ const dates = [];
18
+ for (const v of values) {
19
+ if (v == null) continue;
20
+ const g = sniffTimeGranularity(v);
21
+ if (g === null) return null;
22
+ if (g !== "day") return g;
23
+ const day = String(v).slice(0, 10);
24
+ if (seen.has(day)) continue;
25
+ seen.add(day);
26
+ const d = /* @__PURE__ */ new Date(`${day}T00:00:00.000Z`);
27
+ if (isNaN(d.getTime())) return null;
28
+ dates.push(d);
29
+ }
30
+ if (dates.length === 0) return null;
31
+ if (dates.length === 1) return "day";
32
+ const times = dates.map((d) => d.getTime()).sort((a, b) => a - b);
33
+ let minGap = Infinity;
34
+ for (let i = 1; i < times.length; i++) minGap = Math.min(minGap, times[i] - times[i - 1]);
35
+ const all = (p) => dates.every(p);
36
+ if (all((d) => d.getUTCDate() === 1)) {
37
+ if (all((d) => d.getUTCMonth() === 0)) return "year";
38
+ if (all((d) => d.getUTCMonth() % 3 === 0) && minGap > 31 * DAY_MS) return "quarter";
39
+ return "month";
40
+ }
41
+ if (all((d) => d.getUTCDay() === dates[0].getUTCDay())) return "week";
42
+ return "day";
43
+ }
44
+ var KIND_SAMPLE = 20;
14
45
  function sniffKind(rows, name) {
15
- const sample = rows.find((r) => r[name] != null)?.[name];
16
- if (sample == null) return "string";
17
- if (typeof sample === "number" || typeof sample === "bigint") return "number";
18
- if (typeof sample === "boolean") return "boolean";
19
- if (typeof sample === "string" && sniffTimeGranularity(sample) !== null) return "time";
20
- return "string";
46
+ const kindOf = (v) => {
47
+ if (typeof v === "number" || typeof v === "bigint") return "number";
48
+ if (typeof v === "boolean") return "boolean";
49
+ if (typeof v === "string" && sniffTimeGranularity(v) !== null) return "time";
50
+ return "string";
51
+ };
52
+ let kind;
53
+ let seen = 0;
54
+ for (const r of rows) {
55
+ const v = r[name];
56
+ if (v == null) continue;
57
+ const k = kindOf(v);
58
+ if (kind === void 0) kind = k;
59
+ else if (k !== kind) return "string";
60
+ if (++seen >= KIND_SAMPLE) break;
61
+ }
62
+ return kind ?? "string";
21
63
  }
22
64
  function roleFromKind(kind) {
23
65
  if (kind === "time") return "time";
@@ -27,9 +69,17 @@ function roleFromKind(kind) {
27
69
  function titleCase(s) {
28
70
  return s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
29
71
  }
30
- function formatHint(name) {
72
+ function formatHint(name, values) {
31
73
  const n = name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
32
- if (/(^|_)(rate|pct|percent|percentage|ratio)(_|$)/.test(n)) return "percent";
74
+ if (/(^|_)(pct|percent|percentage)(_|$)/.test(n)) return "percent";
75
+ if (/(^|_)(rate|ratio)(_|$)/.test(n)) {
76
+ for (const v of values ?? []) {
77
+ if (v == null) continue;
78
+ const x = Number(v);
79
+ if (Number.isFinite(x) && Math.abs(x) > 1) return void 0;
80
+ }
81
+ return "percent";
82
+ }
33
83
  return void 0;
34
84
  }
35
85
  function granularityHint(name) {
@@ -49,8 +99,8 @@ function inferFields(data) {
49
99
  const d = declared.get(name);
50
100
  const kind = d?.kind ?? sniffKind(rows, name);
51
101
  const role = d?.role ?? roleFromKind(kind);
52
- const granularity = d?.granularity ?? (kind === "time" ? sniffTimeGranularity(rows.find((r) => r[name] != null)?.[name]) ?? granularityHint(name) : void 0);
53
- const format = d?.format ?? (kind === "number" ? formatHint(name) : void 0);
102
+ const granularity = d?.granularity ?? (kind === "time" ? sniffGranularityFromValues(rows.map((r) => r[name])) ?? granularityHint(name) : void 0);
103
+ const format = d?.format ?? (kind === "number" ? formatHint(name, rows.map((r) => r[name])) : void 0);
54
104
  return {
55
105
  name,
56
106
  kind,
@@ -95,10 +145,16 @@ function buildChartData({
95
145
  mapKind,
96
146
  normalizeCell = defaultNormalizeCell
97
147
  }) {
148
+ const kindByName = new Map(columns.map((col) => [col.name, mapKind(col.type, col)]));
149
+ const out = rows.map((row) => {
150
+ const r = {};
151
+ for (const col of columns) r[col.name] = normalizeCell(row[col.name], kindByName.get(col.name), col);
152
+ return r;
153
+ });
98
154
  const fields = columns.map((col) => {
99
- const kind = mapKind(col.type, col);
100
- const format = kind === "number" ? formatHint(col.name) : void 0;
101
- const granularity = kind === "time" ? granularityHint(col.name) : void 0;
155
+ const kind = kindByName.get(col.name);
156
+ const format = kind === "number" ? formatHint(col.name, out.map((r) => r[col.name])) : void 0;
157
+ const granularity = kind === "time" ? sniffGranularityFromValues(out.map((r) => r[col.name])) ?? granularityHint(col.name) : void 0;
102
158
  return {
103
159
  name: col.name,
104
160
  kind,
@@ -108,12 +164,6 @@ function buildChartData({
108
164
  ...granularity && { granularity }
109
165
  };
110
166
  });
111
- const kindByName = new Map(fields.map((f) => [f.name, f.kind]));
112
- const out = rows.map((row) => {
113
- const r = {};
114
- for (const col of columns) r[col.name] = normalizeCell(row[col.name], kindByName.get(col.name), col);
115
- return r;
116
- });
117
167
  return { rows: out, fields };
118
168
  }
119
169
  function assertReadOnlySql(sql) {
@@ -133,4 +183,4 @@ export {
133
183
  buildChartData,
134
184
  assertReadOnlySql
135
185
  };
136
- //# sourceMappingURL=chunk-NGXV4I2L.js.map
186
+ //# sourceMappingURL=chunk-I3HINKHN.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/resolve/infer.ts","../src/adapters/sql.ts"],"sourcesContent":["// Typing inference: turn a ChartData into a complete, role-tagged FieldMeta list.\n// Priority: declared fields win; anything missing is sniffed from the row values.\n// This is what lets resolve() decide x / y / series when the data source did not\n// supply full typing (e.g. raw SQL rows).\nimport type { ChartData, FieldFormat, FieldKind, FieldMeta, FieldRole, TimeGranularity } from \"../types.js\";\n\n// Period-string -> granularity. Lets the raw-SQL path recognize time buckets that aren't full ISO\n// dates (e.g. strftime('%Y-%m') -> \"2025-01\") so time series are typed TEMPORAL — ordered, drawn\n// vertically, nicely labelled — instead of being mistaken for high-cardinality categories. Used\n// when the data source declares no column types, so the kind is inferred from the value shape.\nexport function sniffTimeGranularity(value: unknown): TimeGranularity | null {\n if (typeof value !== \"string\") return null;\n if (/^\\d{4}-\\d{2}-\\d{2}([T ]|$)/.test(value)) return \"day\"; // 2025-01-15 or a full datetime\n if (/^\\d{4}-\\d{2}$/.test(value)) return \"month\"; // 2025-01\n if (/^\\d{4}-?Q[1-4]$/i.test(value)) return \"quarter\"; // 2025-Q1 / 2025Q1\n if (/^\\d{4}-W\\d{2}$/i.test(value)) return \"week\"; // 2025-W03 (ISO week)\n if (/^\\d{4}$/.test(value)) {\n const y = Number(value); // bare year as a string (\"2025\"); guard the range to avoid 4-digit codes\n if (y >= 1900 && y <= 2100) return \"year\";\n }\n return null;\n}\n\n// Granularity from the actual values. date_trunc('month'|'quarter'|'year'|'week') yields FULL\n// dates, so a single value's shape reads as \"day\" and a monthly series gets ~30x null-filled\n// into disconnected dots. Scan the distinct dates instead: all Jan 1 -> year, all first-of-\n// quarter spaced like quarters -> quarter, all first-of-month -> month, all the same weekday ->\n// week, else day. Period strings (YYYY-MM, YYYY-Qn, ...) state their own granularity; mixed or\n// unparseable values bail out (null).\nconst DAY_MS = 86_400_000;\nexport function sniffGranularityFromValues(values: unknown[]): TimeGranularity | null {\n const seen = new Set<string>();\n const dates: Date[] = [];\n for (const v of values) {\n if (v == null) continue;\n const g = sniffTimeGranularity(v);\n if (g === null) return null;\n if (g !== \"day\") return g;\n const day = String(v).slice(0, 10);\n if (seen.has(day)) continue;\n seen.add(day);\n const d = new Date(`${day}T00:00:00.000Z`);\n if (isNaN(d.getTime())) return null;\n dates.push(d);\n }\n if (dates.length === 0) return null;\n if (dates.length === 1) return \"day\"; // one distinct date: no periodicity to read\n const times = dates.map((d) => d.getTime()).sort((a, b) => a - b);\n let minGap = Infinity;\n for (let i = 1; i < times.length; i++) minGap = Math.min(minGap, times[i]! - times[i - 1]!);\n const all = (p: (d: Date) => boolean) => dates.every(p);\n if (all((d) => d.getUTCDate() === 1)) {\n if (all((d) => d.getUTCMonth() === 0)) return \"year\";\n // First-of-quarter alone also matches monthly data (Jan, Feb, ...); the gap disambiguates.\n if (all((d) => d.getUTCMonth() % 3 === 0) && minGap > 31 * DAY_MS) return \"quarter\";\n return \"month\";\n }\n if (all((d) => d.getUTCDay() === dates[0]!.getUTCDay())) return \"week\";\n return \"day\";\n}\n\n// One value can lie (a text column whose first value is \"2024\" or \"2025-01-15\" would type the\n// whole column time/number), so sample several and require consensus; disagreement -> string.\nconst KIND_SAMPLE = 20;\nexport function sniffKind(rows: Record<string, unknown>[], name: string): FieldKind {\n const kindOf = (v: unknown): FieldKind => {\n if (typeof v === \"number\" || typeof v === \"bigint\") return \"number\";\n if (typeof v === \"boolean\") return \"boolean\";\n if (typeof v === \"string\" && sniffTimeGranularity(v) !== null) return \"time\";\n return \"string\";\n };\n let kind: FieldKind | undefined;\n let seen = 0;\n for (const r of rows) {\n const v = r[name];\n if (v == null) continue;\n const k = kindOf(v);\n if (kind === undefined) kind = k;\n else if (k !== kind) return \"string\";\n if (++seen >= KIND_SAMPLE) break;\n }\n return kind ?? \"string\";\n}\n\nexport function roleFromKind(kind: FieldKind): FieldRole {\n if (kind === \"time\") return \"time\";\n if (kind === \"number\") return \"measure\";\n return \"dimension\";\n}\n\nexport function titleCase(s: string): string {\n return s.replace(/_/g, \" \").replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\n// Name-based hints — a purely conventional fallback used only when the data source declares no\n// format/granularity. Centralized here so the adapter path (buildChartData) and the raw-rows path\n// (inferFields) apply identical rules.\n\n// A numeric column whose name reads as a percentage renders as a percent. pct/percent/percentage\n// names are unambiguous; rate/ratio names are not (exchange_rate at 1.08, hourly_rate at 45), so\n// they only hold when no value contradicts a 0-1 fraction reading. Currency is deliberately NOT\n// guessed from names (the code is unknowable) — declare it. camelCase is normalized to snake.\nexport function formatHint(name: string, values?: unknown[]): FieldFormat | undefined {\n const n = name.replace(/([a-z0-9])([A-Z])/g, \"$1_$2\").toLowerCase();\n if (/(^|_)(pct|percent|percentage)(_|$)/.test(n)) return \"percent\";\n if (/(^|_)(rate|ratio)(_|$)/.test(n)) {\n for (const v of values ?? []) {\n if (v == null) continue;\n const x = Number(v);\n if (Number.isFinite(x) && Math.abs(x) > 1) return undefined;\n }\n return \"percent\";\n }\n return undefined;\n}\n\n// Granularity from the column name, for time columns whose values don't reveal it (e.g. a DATE\n// named \"week_start\" holding full dates). Defaults to day.\nexport function granularityHint(name: string): TimeGranularity {\n const n = name.toLowerCase();\n if (n.includes(\"year\")) return \"year\";\n if (n.includes(\"quarter\")) return \"quarter\";\n if (n.includes(\"month\")) return \"month\";\n if (n.includes(\"week\")) return \"week\";\n return \"day\";\n}\n\n/** Complete field metadata for every column, declared values taking precedence. */\nexport function inferFields(data: ChartData): FieldMeta[] {\n const { rows, fields } = data;\n const declared = new Map((fields ?? []).map((f) => [f.name, f]));\n // Union of returned columns + any declared-only names: declaring fields for SOME columns\n // augments their typing, it does not drop the columns you didn't declare.\n const rowKeys = rows[0] ? Object.keys(rows[0]) : [];\n const names = [...rowKeys, ...[...declared.keys()].filter((n) => !rowKeys.includes(n))];\n\n return names.map((name) => {\n const d = declared.get(name);\n const kind: FieldKind = d?.kind ?? sniffKind(rows, name);\n const role: FieldRole = d?.role ?? roleFromKind(kind);\n // Time columns with no declared granularity: infer it from the values (YYYY-MM -> month,\n // all first-of-month full dates -> month). declared > value-scan > name-hint\n const granularity =\n d?.granularity ??\n (kind === \"time\"\n ? (sniffGranularityFromValues(rows.map((r) => r[name])) ?? granularityHint(name))\n : undefined);\n // declared > name-hint (rate -> percent); only for numeric columns\n const format = d?.format ?? (kind === \"number\" ? formatHint(name, rows.map((r) => r[name])) : undefined);\n return {\n name,\n kind,\n role,\n label: d?.label ?? titleCase(name),\n ...(format && { format }),\n ...(granularity && { granularity }),\n ...(d?.additive != null && { additive: d.additive }),\n ...(d?.currency && { currency: d.currency }),\n };\n });\n}\n","// Shared adapter core. An adapter's only engine-specific jobs are (a) report each column's engine\n// type, (b) map that type to a FieldKind, and (c) normalize a cell to a flat scalar. The rest —\n// assembling typed FieldMeta (with name-based format/granularity hints), the ChartData shape, and\n// the read-only check — lives here so every adapter shares it.\n//\n// Connection, auth, read-only enforcement, cost caps and timeouts belong to the caller's database\n// role; assertReadOnlySql is a cheap extra check on LLM-authored SQL, not a substitute for one.\nimport type { ChartData, FieldKind, FieldMeta } from \"../types.js\";\nimport { roleFromKind, titleCase, formatHint, granularityHint, sniffGranularityFromValues } from \"../resolve/infer.js\";\n\n/** A source column: its name + the engine's declared type, in whatever shape the driver reports. */\nexport interface SourceColumn {\n name: string;\n /** Engine type, passed verbatim to mapKind (e.g. \"INT64\", a Postgres OID, \"VARCHAR\", a field object). */\n type: unknown;\n}\n\n/** Map an engine's column type to our FieldKind. The one piece of real per-engine work. */\nexport type KindMapper = (type: unknown, column: SourceColumn) => FieldKind;\n\n/** Coerce one cell value to a flat scalar, guided by the resolved kind. */\nexport type CellNormalizer = (value: unknown, kind: FieldKind, column: SourceColumn) => unknown;\n\nexport interface BuildChartDataOptions {\n rows: Record<string, unknown>[];\n columns: SourceColumn[];\n mapKind: KindMapper;\n /** Optional; defaults to {@link defaultNormalizeCell}, which suits drivers that return JS-native values. */\n normalizeCell?: CellNormalizer;\n}\n\n/** Default cell normalizer: pass scalars through, coerce numbers/booleans, stringify non-scalars.\n * Handles `{ value }` wrapper objects (some drivers wrap typed values). Adapters whose driver\n * needs special handling (e.g. BigQuery's typed-string + wrapper mix) supply their own. */\nexport function defaultNormalizeCell(value: unknown, kind: FieldKind): unknown {\n if (value == null) return null;\n const obj = typeof value === \"object\" ? (value as Record<string, unknown>) : null;\n if (kind === \"number\") {\n const raw = obj ? (\"value\" in obj ? obj.value : (value as { toString(): string }).toString()) : value;\n const n = Number(raw);\n return Number.isFinite(n) ? n : null;\n }\n if (kind === \"boolean\") {\n if (typeof value === \"boolean\") return value;\n const s = String(obj && \"value\" in obj ? obj.value : value).toLowerCase();\n return s === \"true\" || s === \"1\" ? true : s === \"false\" || s === \"0\" ? false : null;\n }\n if (kind === \"time\") {\n if (obj && \"value\" in obj) return String(obj.value);\n return value instanceof Date ? value.toISOString() : typeof value === \"string\" ? value : String(value);\n }\n // string-ish: unwrap { value }, stringify arrays/objects so the scalar guard passes, else pass through.\n if (obj) {\n if (Array.isArray(value)) return JSON.stringify(value);\n if (\"value\" in obj) return String(obj.value);\n return JSON.stringify(value);\n }\n return value;\n}\n\n/**\n * Turn a SQL result (rows + column types) into typed ChartData. Builds a complete FieldMeta per\n * column from the engine kind + name-based format/granularity hints, then normalizes every cell.\n */\nexport function buildChartData({\n rows,\n columns,\n mapKind,\n normalizeCell = defaultNormalizeCell,\n}: BuildChartDataOptions): ChartData {\n const kindByName = new Map(columns.map((col) => [col.name, mapKind(col.type, col)]));\n const out = rows.map((row) => {\n const r: Record<string, unknown> = {};\n for (const col of columns) r[col.name] = normalizeCell(row[col.name], kindByName.get(col.name)!, col);\n return r;\n });\n const fields: FieldMeta[] = columns.map((col) => {\n const kind = kindByName.get(col.name)!;\n const format = kind === \"number\" ? formatHint(col.name, out.map((r) => r[col.name])) : undefined;\n // Granularity from the normalized values (all first-of-month -> month); name-hint fallback.\n const granularity =\n kind === \"time\"\n ? (sniffGranularityFromValues(out.map((r) => r[col.name])) ?? granularityHint(col.name))\n : undefined;\n return {\n name: col.name,\n kind,\n role: roleFromKind(kind),\n label: titleCase(col.name),\n ...(format && { format }),\n ...(granularity && { granularity }),\n };\n });\n return { rows: out, fields };\n}\n\n/**\n * Reject anything that isn't a single SELECT/WITH statement. A cheap check on LLM-authored SQL;\n * enforce real read-only access with a database role.\n */\nexport function assertReadOnlySql(sql: string): void {\n const t = sql.trim().replace(/;\\s*$/, \"\");\n if (/;/.test(t)) throw new Error(\"Only a single statement is allowed.\");\n if (!/^(select|with)\\b/i.test(t)) throw new Error(\"Only read-only SELECT queries are allowed.\");\n if (\n /\\b(insert|update|delete|merge|drop|alter|create|truncate|grant|revoke|call|export|load|copy|execute|attach|vacuum|lock)\\b/i.test(\n t,\n )\n ) {\n throw new Error(\"Write/DDL statements are not allowed.\");\n }\n}\n"],"mappings":";AAUO,SAAS,qBAAqB,OAAwC;AAC3E,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,6BAA6B,KAAK,KAAK,EAAG,QAAO;AACrD,MAAI,gBAAgB,KAAK,KAAK,EAAG,QAAO;AACxC,MAAI,mBAAmB,KAAK,KAAK,EAAG,QAAO;AAC3C,MAAI,kBAAkB,KAAK,KAAK,EAAG,QAAO;AAC1C,MAAI,UAAU,KAAK,KAAK,GAAG;AACzB,UAAM,IAAI,OAAO,KAAK;AACtB,QAAI,KAAK,QAAQ,KAAK,KAAM,QAAO;AAAA,EACrC;AACA,SAAO;AACT;AAQA,IAAM,SAAS;AACR,SAAS,2BAA2B,QAA2C;AACpF,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,QAAgB,CAAC;AACvB,aAAW,KAAK,QAAQ;AACtB,QAAI,KAAK,KAAM;AACf,UAAM,IAAI,qBAAqB,CAAC;AAChC,QAAI,MAAM,KAAM,QAAO;AACvB,QAAI,MAAM,MAAO,QAAO;AACxB,UAAM,MAAM,OAAO,CAAC,EAAE,MAAM,GAAG,EAAE;AACjC,QAAI,KAAK,IAAI,GAAG,EAAG;AACnB,SAAK,IAAI,GAAG;AACZ,UAAM,IAAI,oBAAI,KAAK,GAAG,GAAG,gBAAgB;AACzC,QAAI,MAAM,EAAE,QAAQ,CAAC,EAAG,QAAO;AAC/B,UAAM,KAAK,CAAC;AAAA,EACd;AACA,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,QAAQ,MAAM,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AAChE,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,IAAK,UAAS,KAAK,IAAI,QAAQ,MAAM,CAAC,IAAK,MAAM,IAAI,CAAC,CAAE;AAC1F,QAAM,MAAM,CAAC,MAA4B,MAAM,MAAM,CAAC;AACtD,MAAI,IAAI,CAAC,MAAM,EAAE,WAAW,MAAM,CAAC,GAAG;AACpC,QAAI,IAAI,CAAC,MAAM,EAAE,YAAY,MAAM,CAAC,EAAG,QAAO;AAE9C,QAAI,IAAI,CAAC,MAAM,EAAE,YAAY,IAAI,MAAM,CAAC,KAAK,SAAS,KAAK,OAAQ,QAAO;AAC1E,WAAO;AAAA,EACT;AACA,MAAI,IAAI,CAAC,MAAM,EAAE,UAAU,MAAM,MAAM,CAAC,EAAG,UAAU,CAAC,EAAG,QAAO;AAChE,SAAO;AACT;AAIA,IAAM,cAAc;AACb,SAAS,UAAU,MAAiC,MAAyB;AAClF,QAAM,SAAS,CAAC,MAA0B;AACxC,QAAI,OAAO,MAAM,YAAY,OAAO,MAAM,SAAU,QAAO;AAC3D,QAAI,OAAO,MAAM,UAAW,QAAO;AACnC,QAAI,OAAO,MAAM,YAAY,qBAAqB,CAAC,MAAM,KAAM,QAAO;AACtE,WAAO;AAAA,EACT;AACA,MAAI;AACJ,MAAI,OAAO;AACX,aAAW,KAAK,MAAM;AACpB,UAAM,IAAI,EAAE,IAAI;AAChB,QAAI,KAAK,KAAM;AACf,UAAM,IAAI,OAAO,CAAC;AAClB,QAAI,SAAS,OAAW,QAAO;AAAA,aACtB,MAAM,KAAM,QAAO;AAC5B,QAAI,EAAE,QAAQ,YAAa;AAAA,EAC7B;AACA,SAAO,QAAQ;AACjB;AAEO,SAAS,aAAa,MAA4B;AACvD,MAAI,SAAS,OAAQ,QAAO;AAC5B,MAAI,SAAS,SAAU,QAAO;AAC9B,SAAO;AACT;AAEO,SAAS,UAAU,GAAmB;AAC3C,SAAO,EAAE,QAAQ,MAAM,GAAG,EAAE,QAAQ,SAAS,CAAC,MAAM,EAAE,YAAY,CAAC;AACrE;AAUO,SAAS,WAAW,MAAc,QAA6C;AACpF,QAAM,IAAI,KAAK,QAAQ,sBAAsB,OAAO,EAAE,YAAY;AAClE,MAAI,qCAAqC,KAAK,CAAC,EAAG,QAAO;AACzD,MAAI,yBAAyB,KAAK,CAAC,GAAG;AACpC,eAAW,KAAK,UAAU,CAAC,GAAG;AAC5B,UAAI,KAAK,KAAM;AACf,YAAM,IAAI,OAAO,CAAC;AAClB,UAAI,OAAO,SAAS,CAAC,KAAK,KAAK,IAAI,CAAC,IAAI,EAAG,QAAO;AAAA,IACpD;AACA,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAIO,SAAS,gBAAgB,MAA+B;AAC7D,QAAM,IAAI,KAAK,YAAY;AAC3B,MAAI,EAAE,SAAS,MAAM,EAAG,QAAO;AAC/B,MAAI,EAAE,SAAS,SAAS,EAAG,QAAO;AAClC,MAAI,EAAE,SAAS,OAAO,EAAG,QAAO;AAChC,MAAI,EAAE,SAAS,MAAM,EAAG,QAAO;AAC/B,SAAO;AACT;AAGO,SAAS,YAAY,MAA8B;AACxD,QAAM,EAAE,MAAM,OAAO,IAAI;AACzB,QAAM,WAAW,IAAI,KAAK,UAAU,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;AAG/D,QAAM,UAAU,KAAK,CAAC,IAAI,OAAO,KAAK,KAAK,CAAC,CAAC,IAAI,CAAC;AAClD,QAAM,QAAQ,CAAC,GAAG,SAAS,GAAG,CAAC,GAAG,SAAS,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,QAAQ,SAAS,CAAC,CAAC,CAAC;AAEtF,SAAO,MAAM,IAAI,CAAC,SAAS;AACzB,UAAM,IAAI,SAAS,IAAI,IAAI;AAC3B,UAAM,OAAkB,GAAG,QAAQ,UAAU,MAAM,IAAI;AACvD,UAAM,OAAkB,GAAG,QAAQ,aAAa,IAAI;AAGpD,UAAM,cACJ,GAAG,gBACF,SAAS,SACL,2BAA2B,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,gBAAgB,IAAI,IAC7E;AAEN,UAAM,SAAS,GAAG,WAAW,SAAS,WAAW,WAAW,MAAM,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,IAAI;AAC9F,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,GAAG,SAAS,UAAU,IAAI;AAAA,MACjC,GAAI,UAAU,EAAE,OAAO;AAAA,MACvB,GAAI,eAAe,EAAE,YAAY;AAAA,MACjC,GAAI,GAAG,YAAY,QAAQ,EAAE,UAAU,EAAE,SAAS;AAAA,MAClD,GAAI,GAAG,YAAY,EAAE,UAAU,EAAE,SAAS;AAAA,IAC5C;AAAA,EACF,CAAC;AACH;;;AC9HO,SAAS,qBAAqB,OAAgB,MAA0B;AAC7E,MAAI,SAAS,KAAM,QAAO;AAC1B,QAAM,MAAM,OAAO,UAAU,WAAY,QAAoC;AAC7E,MAAI,SAAS,UAAU;AACrB,UAAM,MAAM,MAAO,WAAW,MAAM,IAAI,QAAS,MAAiC,SAAS,IAAK;AAChG,UAAM,IAAI,OAAO,GAAG;AACpB,WAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AAAA,EAClC;AACA,MAAI,SAAS,WAAW;AACtB,QAAI,OAAO,UAAU,UAAW,QAAO;AACvC,UAAM,IAAI,OAAO,OAAO,WAAW,MAAM,IAAI,QAAQ,KAAK,EAAE,YAAY;AACxE,WAAO,MAAM,UAAU,MAAM,MAAM,OAAO,MAAM,WAAW,MAAM,MAAM,QAAQ;AAAA,EACjF;AACA,MAAI,SAAS,QAAQ;AACnB,QAAI,OAAO,WAAW,IAAK,QAAO,OAAO,IAAI,KAAK;AAClD,WAAO,iBAAiB,OAAO,MAAM,YAAY,IAAI,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK;AAAA,EACvG;AAEA,MAAI,KAAK;AACP,QAAI,MAAM,QAAQ,KAAK,EAAG,QAAO,KAAK,UAAU,KAAK;AACrD,QAAI,WAAW,IAAK,QAAO,OAAO,IAAI,KAAK;AAC3C,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B;AACA,SAAO;AACT;AAMO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA,gBAAgB;AAClB,GAAqC;AACnC,QAAM,aAAa,IAAI,IAAI,QAAQ,IAAI,CAAC,QAAQ,CAAC,IAAI,MAAM,QAAQ,IAAI,MAAM,GAAG,CAAC,CAAC,CAAC;AACnF,QAAM,MAAM,KAAK,IAAI,CAAC,QAAQ;AAC5B,UAAM,IAA6B,CAAC;AACpC,eAAW,OAAO,QAAS,GAAE,IAAI,IAAI,IAAI,cAAc,IAAI,IAAI,IAAI,GAAG,WAAW,IAAI,IAAI,IAAI,GAAI,GAAG;AACpG,WAAO;AAAA,EACT,CAAC;AACD,QAAM,SAAsB,QAAQ,IAAI,CAAC,QAAQ;AAC/C,UAAM,OAAO,WAAW,IAAI,IAAI,IAAI;AACpC,UAAM,SAAS,SAAS,WAAW,WAAW,IAAI,MAAM,IAAI,IAAI,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,CAAC,IAAI;AAEvF,UAAM,cACJ,SAAS,SACJ,2BAA2B,IAAI,IAAI,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,CAAC,KAAK,gBAAgB,IAAI,IAAI,IACpF;AACN,WAAO;AAAA,MACL,MAAM,IAAI;AAAA,MACV;AAAA,MACA,MAAM,aAAa,IAAI;AAAA,MACvB,OAAO,UAAU,IAAI,IAAI;AAAA,MACzB,GAAI,UAAU,EAAE,OAAO;AAAA,MACvB,GAAI,eAAe,EAAE,YAAY;AAAA,IACnC;AAAA,EACF,CAAC;AACD,SAAO,EAAE,MAAM,KAAK,OAAO;AAC7B;AAMO,SAAS,kBAAkB,KAAmB;AACnD,QAAM,IAAI,IAAI,KAAK,EAAE,QAAQ,SAAS,EAAE;AACxC,MAAI,IAAI,KAAK,CAAC,EAAG,OAAM,IAAI,MAAM,qCAAqC;AACtE,MAAI,CAAC,oBAAoB,KAAK,CAAC,EAAG,OAAM,IAAI,MAAM,4CAA4C;AAC9F,MACE,6HAA6H;AAAA,IAC3H;AAAA,EACF,GACA;AACA,UAAM,IAAI,MAAM,uCAAuC;AAAA,EACzD;AACF;","names":[]}
@@ -1,5 +1,5 @@
1
1
  import { DBSQLSession } from '@databricks/sql';
2
- import { C as ChartData } from './types-CHiUU9mj.js';
2
+ import { C as ChartData } from './types-6ALzXWjE.js';
3
3
 
4
4
  /** A Databricks result column: name + the Thrift TTypeId code. */
5
5
  interface DatabricksColumn {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  assertReadOnlySql,
3
3
  buildChartData
4
- } from "./chunk-NGXV4I2L.js";
4
+ } from "./chunk-I3HINKHN.js";
5
5
 
6
6
  // src/adapters/databricks.ts
7
7
  var TTypeId = {
package/dist/duckdb.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DuckDBType, DuckDBConnection } from '@duckdb/node-api';
2
- import { C as ChartData } from './types-CHiUU9mj.js';
2
+ import { C as ChartData } from './types-6ALzXWjE.js';
3
3
 
4
4
  /** A DuckDB result column: its name + the DuckDBType (from reader.columnTypes()). */
5
5
  interface DuckDbColumn {
package/dist/duckdb.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  assertReadOnlySql,
3
3
  buildChartData
4
- } from "./chunk-NGXV4I2L.js";
4
+ } from "./chunk-I3HINKHN.js";
5
5
 
6
6
  // src/adapters/duckdb.ts
7
7
  import { DuckDBTypeId } from "@duckdb/node-api";
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as ChartData, R as ResolveOptions, a as ChartSpec, F as FieldMeta, b as ChartContext, c as ChartType, d as FieldKind } from './types-CHiUU9mj.js';
2
- export { A as AxisSpec, e as ColumnSpec, E as Encode, f as FieldFormat, g as FieldRole, h as ReferenceLine, S as SeriesSpec, i as Stacking, T as TimeGranularity } from './types-CHiUU9mj.js';
1
+ import { C as ChartData, R as ResolveOptions, a as ChartSpec, F as FieldMeta, b as ChartContext, c as ChartType, d as FieldKind } from './types-6ALzXWjE.js';
2
+ export { A as AxisSpec, e as ColumnSpec, E as Encode, f as FieldFormat, g as FieldRole, h as ReferenceLine, S as SeriesSpec, i as Stacking, T as TimeGranularity } from './types-6ALzXWjE.js';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
 
5
5
  declare function resolve(data: ChartData, opts?: ResolveOptions): ChartSpec;