diff --git a/README.md b/README.md
index c144224..b5b9e0d 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Flint: A Semantic Visualization Language for AI Agents and Humans
+# Flint: A Visualization Language for the AI Era
[](https://www.npmjs.com/package/flint-chart)
[](https://www.npmjs.com/package/flint-chart-mcp)
@@ -111,8 +111,8 @@ includes client configuration, usage examples, and links to deeper references.
-MCP calls let agents embed rows directly as `data.values`, or read configured
-local JSON, CSV, or TSV files by `data.url`. For agent workflows without MCP,
+MCP calls let agents embed rows directly as `data.values`, or read local JSON,
+CSV, or TSV files by `data.url`. For agent workflows without MCP,
use the standalone [agent skill](agent-skills/flint-chart-author/SKILL.md).
## Repository overview
diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md
index 1786f23..216d0d4 100644
--- a/agent-skills/flint-chart-author/SKILL.md
+++ b/agent-skills/flint-chart-author/SKILL.md
@@ -104,13 +104,15 @@ Use the binding mode that matches the runtime. Do not mix them.
data is small or already transformed by another tool, pass it as
`data: { values: [...] }`. Do not pass runtime variable names in
MCP tool calls — the MCP server cannot see your local variables.
-2. **Direct MCP rendering: reference a local file only when configured.**
+2. **Direct MCP rendering: reference a local file.**
The `flint-chart-mcp` server can load `data: { url: "..." }` from local
- `.json`, `.csv`, or `.tsv` files, but only under directories that the host
- explicitly allowed with `--data-roots`, `--data-root`, or
- `FLINT_MCP_DATA_ROOTS`. Remote URL fetching is disabled. If the data must be
- transformed first, use a coding/data tool to write a small prepared file
- under an allowed root, then reference that file.
+ `.json`, `.csv`, or `.tsv` files. By default any local file the agent can
+ name is readable (relative paths resolve against the working directory); a
+ hardened deployment may reject local file references entirely via
+ `--disable-file-reference` (or `FLINT_MCP_DISABLE_FILE_REFERENCE`), in which
+ case pass rows inline with `data.values`. Remote URL
+ fetching is disabled. If the data must be transformed first, use a
+ coding/data tool to write a small prepared file, then reference that file.
3. **Generated application or notebook code: bind runtime variables.** If the
user asks you to add Flint to code, write normal data-loading code first and
pass a real runtime value, e.g. `data: { values: rows }`, to
@@ -262,7 +264,7 @@ string shorthand, expanded to `{ field: "" }`):
|---|---|---|
| `field` | column name | Bind the channel to a data column |
| `type` | `quantitative`, `nominal`, `ordinal`, `temporal` | Override the inferred encoding type (rarely needed) |
-| `aggregate` | `count`, `sum`, `average` | Force an aggregation on a measure channel |
+| `aggregate` | `count`, `sum`, `average`, `mean` | Force an aggregation on a measure channel |
| `sortOrder` | `ascending`, `descending` | Sort direction for a discrete/sorted axis |
| `sortBy` | channel name (e.g. `"y"`) or field | Sort a category axis by another channel's measure |
| `scheme` | Vega scheme name (e.g. `viridis`, `redblue`) | Color scheme for the `color` channel |
diff --git a/data/Localization_results.csv b/data/Localization_results.csv
deleted file mode 100644
index 3d44969..0000000
--- a/data/Localization_results.csv
+++ /dev/null
@@ -1,721 +0,0 @@
-userId,dataset,method,position,value,time
-1,20210701,Viridis,1,A,5.073
-1,20210701,Rainbow,1,B,2.253
-1,20210709,Ours,1,B,2.797
-1,20210729,Ours,1,B,2.002
-1,20210718,Rainbow2,1,B,2.031
-1,20210709,Viridis2,1,D,1.796
-1,20210709,OMC,1,D,2.975
-1,20210729,Viridis,1,A,2.882
-1,20210718,Viridis,1,C,3.412
-1,20210718,OMC,1,C,4.321
-1,20210729,Viridis2,1,C,35.447
-1,20210718,Viridis2,1,D,1.701
-1,20210701,Ours,1,A,1.866
-1,20210729,Rainbow2,1,D,4.59
-1,20210718,Ours,1,D,4.284
-1,20210709,Rainbow,1,D,31.971
-1,20210718,Rainbow,1,B,4.899
-1,20210729,OMC,1,B,5.005
-1,20210701,OMC,1,C,3.932
-1,20210709,Viridis,1,D,3.757
-1,20210729,Rainbow,1,C,4.287
-1,20210701,Viridis2,1,A,1.964
-1,20210701,Rainbow2,1,A,4.263
-1,20210709,Rainbow2,1,B,2.874
-2,20210701,Viridis,1,D,10.399
-2,20210701,Rainbow,1,B,3.027
-2,20210709,Ours,1,C,6.852
-2,20210729,Ours,1,D,8.805
-2,20210718,Rainbow2,1,B,3.939
-2,20210709,Viridis2,1,D,4.941
-2,20210709,OMC,1,B,12.63
-2,20210729,Viridis,1,D,6.27
-2,20210718,Viridis,1,B,5.665
-2,20210718,OMC,1,C,3.73
-2,20210729,Viridis2,1,C,5.365
-2,20210718,Viridis2,1,A,5.469
-2,20210701,Ours,1,A,5.813
-2,20210729,Rainbow2,1,B,4.75
-2,20210718,Ours,1,C,6.427
-2,20210709,Rainbow,1,B,7.309
-2,20210718,Rainbow,1,D,4.501
-2,20210729,OMC,1,B,5.508
-2,20210701,OMC,1,D,27.472
-2,20210709,Viridis,1,B,6.669
-2,20210729,Rainbow,1,D,4.396
-2,20210701,Viridis2,1,D,5.82
-2,20210701,Rainbow2,1,A,3.594
-2,20210709,Rainbow2,1,B,3.948
-3,20210701,Viridis,1,A,6.532
-3,20210701,Rainbow,1,B,5.111
-3,20210709,Ours,1,B,6.131
-3,20210729,Ours,1,D,7.181
-3,20210718,Rainbow2,1,B,6.479
-3,20210709,Viridis2,1,B,4.837
-3,20210709,OMC,1,D,5.471
-3,20210729,Viridis,1,B,5.642
-3,20210718,Viridis,1,B,6.232
-3,20210718,OMC,1,A,11.153
-3,20210729,Viridis2,1,C,6.843
-3,20210718,Viridis2,1,A,4.553
-3,20210701,Ours,1,A,8.299
-3,20210729,Rainbow2,1,C,8.453
-3,20210718,Ours,1,C,3.694
-3,20210709,Rainbow,1,D,6.211
-3,20210718,Rainbow,1,D,4.125
-3,20210729,OMC,1,B,6.082
-3,20210701,OMC,1,C,5.853
-3,20210709,Viridis,1,D,4.947
-3,20210729,Rainbow,1,D,10.407
-3,20210701,Viridis2,1,A,2.461
-3,20210701,Rainbow2,1,A,4.025
-3,20210709,Rainbow2,1,B,6.919
-4,20210701,Viridis,1,A,3.831
-4,20210701,Rainbow,1,C,11.767
-4,20210709,Ours,1,B,7.359
-4,20210729,Ours,1,D,6.431
-4,20210718,Rainbow2,1,B,3.815
-4,20210709,Viridis2,1,D,7.559
-4,20210709,OMC,1,B,16.144
-4,20210729,Viridis,1,B,9.686
-4,20210718,Viridis,1,B,8.839
-4,20210718,OMC,1,C,5.639
-4,20210729,Viridis2,1,C,4.399
-4,20210718,Viridis2,1,A,6.007
-4,20210701,Ours,1,A,4.319
-4,20210729,Rainbow2,1,C,4.719
-4,20210718,Ours,1,C,4.159
-4,20210709,Rainbow,1,D,5.543
-4,20210718,Rainbow,1,D,3.503
-4,20210729,OMC,1,B,10.799
-4,20210701,OMC,1,C,5.247
-4,20210709,Viridis,1,D,10.383
-4,20210729,Rainbow,1,B,14.183
-4,20210701,Viridis2,1,A,2.711
-4,20210701,Rainbow2,1,A,4.255
-4,20210709,Rainbow2,1,B,3.928
-5,20210701,Viridis,1,A,7.465
-5,20210701,Rainbow,1,C,7.351
-5,20210709,Ours,1,D,12.162
-5,20210729,Ours,1,D,10.583
-5,20210718,Rainbow2,1,B,11.182
-5,20210709,Viridis2,1,D,10.597
-5,20210709,OMC,1,B,6.413
-5,20210729,Viridis,1,B,13.131
-5,20210718,Viridis,1,C,8.25
-5,20210718,OMC,1,C,9.313
-5,20210729,Viridis2,1,C,10.033
-5,20210718,Viridis2,1,A,11.065
-5,20210701,Ours,1,A,10.347
-5,20210729,Rainbow2,1,D,22.137
-5,20210718,Ours,1,C,7.197
-5,20210709,Rainbow,1,C,2.08
-5,20210718,Rainbow,1,A,2.517
-5,20210729,OMC,1,B,3.627
-5,20210701,OMC,1,D,3.333
-5,20210709,Viridis,1,D,2.566
-5,20210729,Rainbow,1,B,6.932
-5,20210701,Viridis2,1,A,3.614
-5,20210701,Rainbow2,1,A,2.097
-5,20210709,Rainbow2,1,B,9.399
-6,20210701,Viridis,1,B,10.256
-6,20210701,Rainbow,1,D,5.896
-6,20210709,Ours,1,B,7.937
-6,20210729,Ours,1,D,6.157
-6,20210718,Rainbow2,1,B,5.389
-6,20210709,Viridis2,1,B,5.742
-6,20210709,OMC,1,D,4.708
-6,20210729,Viridis,1,C,6.62
-6,20210718,Viridis,1,D,4.518
-6,20210718,OMC,1,C,5.14
-6,20210729,Viridis2,1,C,4.738
-6,20210718,Viridis2,1,B,5.3
-6,20210701,Ours,1,A,6.92
-6,20210729,Rainbow2,1,C,8.41
-6,20210718,Ours,1,D,5.315
-6,20210709,Rainbow,1,D,5.919
-6,20210718,Rainbow,1,B,8.216
-6,20210729,OMC,1,B,4.334
-6,20210701,OMC,1,D,4.13
-6,20210709,Viridis,1,D,3.628
-6,20210729,Rainbow,1,C,4.425
-6,20210701,Viridis2,1,A,5.379
-6,20210701,Rainbow2,1,A,5.877
-6,20210709,Rainbow2,1,D,6.898
-7,20210701,Viridis,1,D,6.328
-7,20210701,Rainbow,1,A,3.493
-7,20210709,Ours,1,B,3.389
-7,20210729,Ours,1,D,5.245
-7,20210718,Rainbow2,1,B,9.913
-7,20210709,Viridis2,1,D,3.629
-7,20210709,OMC,1,C,3.823
-7,20210729,Viridis,1,A,3.855
-7,20210718,Viridis,1,B,4.742
-7,20210718,OMC,1,C,2.965
-7,20210729,Viridis2,1,C,5.711
-7,20210718,Viridis2,1,B,4.408
-7,20210701,Ours,1,B,5.103
-7,20210729,Rainbow2,1,A,3.616
-7,20210718,Ours,1,C,4.06
-7,20210709,Rainbow,1,A,3.028
-7,20210718,Rainbow,1,C,4.027
-7,20210729,OMC,1,B,5.907
-7,20210701,OMC,1,C,4.244
-7,20210709,Viridis,1,C,3.235
-7,20210729,Rainbow,1,D,7.666
-7,20210701,Viridis2,1,B,4.232
-7,20210701,Rainbow2,1,A,3.999
-7,20210709,Rainbow2,1,B,2.824
-8,20210701,Viridis,1,A,5.703
-8,20210701,Rainbow,1,B,11.082
-8,20210709,Ours,1,B,5.263
-8,20210729,Ours,1,D,7.356
-8,20210718,Rainbow2,1,B,5.987
-8,20210709,Viridis2,1,B,10.084
-8,20210709,OMC,1,D,4.729
-8,20210729,Viridis,1,C,12.388
-8,20210718,Viridis,1,B,5.659
-8,20210718,OMC,1,C,5.813
-8,20210729,Viridis2,1,C,5.604
-8,20210718,Viridis2,1,A,4.323
-8,20210701,Ours,1,A,6.454
-8,20210729,Rainbow2,1,C,10.57
-8,20210718,Ours,1,C,5.105
-8,20210709,Rainbow,1,D,4.485
-8,20210718,Rainbow,1,D,4.491
-8,20210729,OMC,1,C,4.927
-8,20210701,OMC,1,C,4.782
-8,20210709,Viridis,1,C,6.592
-8,20210729,Rainbow,1,A,6.948
-8,20210701,Viridis2,1,A,4.754
-8,20210701,Rainbow2,1,A,3.482
-8,20210709,Rainbow2,1,B,4.305
-9,20210701,Viridis,1,A,5.053
-9,20210701,Rainbow,1,C,7.754
-9,20210709,Ours,1,B,11.512
-9,20210729,Ours,1,D,14.482
-9,20210718,Rainbow2,1,B,7.58
-9,20210709,Viridis2,1,B,7.839
-9,20210709,OMC,1,D,9.759
-9,20210729,Viridis,1,D,14.51
-9,20210718,Viridis,1,B,11.25
-9,20210718,OMC,1,C,10.193
-9,20210729,Viridis2,1,C,6.381
-9,20210718,Viridis2,1,A,7.928
-9,20210701,Ours,1,A,7.994
-9,20210729,Rainbow2,1,C,9.638
-9,20210718,Ours,1,C,6.41
-9,20210709,Rainbow,1,D,5.707
-9,20210718,Rainbow,1,D,6.119
-9,20210729,OMC,1,B,8.047
-9,20210701,OMC,1,C,7.634
-9,20210709,Viridis,1,D,19.02
-9,20210729,Rainbow,1,A,9.735
-9,20210701,Viridis2,1,A,4.319
-9,20210701,Rainbow2,1,A,9.48
-9,20210709,Rainbow2,1,B,5.098
-10,20210701,Viridis,1,A,7.967
-10,20210701,Rainbow,1,C,10.223
-10,20210709,Ours,1,B,4.054
-10,20210729,Ours,1,D,5.624
-10,20210718,Rainbow2,1,B,7.632
-10,20210709,Viridis2,1,D,6.08
-10,20210709,OMC,1,B,10.159
-10,20210729,Viridis,1,A,12.279
-10,20210718,Viridis,1,C,11.088
-10,20210718,OMC,1,C,4.882
-10,20210729,Viridis2,1,C,6.412
-10,20210718,Viridis2,1,B,5.056
-10,20210701,Ours,1,A,7.631
-10,20210729,Rainbow2,1,C,5.191
-10,20210718,Ours,1,C,8.056
-10,20210709,Rainbow,1,D,4.232
-10,20210718,Rainbow,1,B,4.559
-10,20210729,OMC,1,B,4.11
-10,20210701,OMC,1,C,13.863
-10,20210709,Viridis,1,C,36.225
-10,20210729,Rainbow,1,B,2.27
-10,20210701,Viridis2,1,A,2.648
-10,20210701,Rainbow2,1,B,5.447
-10,20210709,Rainbow2,1,B,3.392
-11,20210701,Viridis,1,D,16.425
-11,20210701,Rainbow,1,B,18.729
-11,20210709,Ours,1,B,6.59
-11,20210729,Ours,1,D,7.518
-11,20210718,Rainbow2,1,B,7.429
-11,20210709,Viridis2,1,D,7.291
-11,20210709,OMC,1,D,15.456
-11,20210729,Viridis,1,D,14.564
-11,20210718,Viridis,1,D,9.811
-11,20210718,OMC,1,C,6.971
-11,20210729,Viridis2,1,B,10.158
-11,20210718,Viridis2,1,A,7.305
-11,20210701,Ours,1,A,12.077
-11,20210729,Rainbow2,1,C,13.368
-11,20210718,Ours,1,B,13.295
-11,20210709,Rainbow,1,D,10.811
-11,20210718,Rainbow,1,B,8.897
-11,20210729,OMC,1,D,5.917
-11,20210701,OMC,1,C,16.688
-11,20210709,Viridis,1,D,7.658
-11,20210729,Rainbow,1,C,6.354
-11,20210701,Viridis2,1,A,7.809
-11,20210701,Rainbow2,1,B,8.173
-11,20210709,Rainbow2,1,B,9.449
-12,20210701,Viridis,1,A,10.33
-12,20210701,Rainbow,1,A,18.998
-12,20210709,Ours,1,B,6.89
-12,20210729,Ours,1,D,8.079
-12,20210718,Rainbow2,1,B,6.028
-12,20210709,Viridis2,1,D,20.106
-12,20210709,OMC,1,B,12.256
-12,20210729,Viridis,1,A,23.697
-12,20210718,Viridis,1,B,10.37
-12,20210718,OMC,1,C,10.575
-12,20210729,Viridis2,1,C,9.96
-12,20210718,Viridis2,1,A,9.6
-12,20210701,Ours,1,A,7.155
-12,20210729,Rainbow2,1,C,7.969
-12,20210718,Ours,1,C,8.479
-12,20210709,Rainbow,1,D,9.689
-12,20210718,Rainbow,1,D,12.279
-12,20210729,OMC,1,B,19.065
-12,20210701,OMC,1,C,10.742
-12,20210709,Viridis,1,C,10.082
-12,20210729,Rainbow,1,D,11.13
-12,20210701,Viridis2,1,A,7.212
-12,20210701,Rainbow2,1,A,7.519
-12,20210709,Rainbow2,1,B,7.651
-13,20210701,Viridis,1,A,7.522
-13,20210701,Rainbow,1,A,14.476
-13,20210709,Ours,1,B,6.481
-13,20210729,Ours,1,D,6.729
-13,20210718,Rainbow2,1,B,6.487
-13,20210709,Viridis2,1,D,17.46
-13,20210709,OMC,1,B,17.662
-13,20210729,Viridis,1,A,13.332
-13,20210718,Viridis,1,B,7.953
-13,20210718,OMC,1,C,8.222
-13,20210729,Viridis2,1,C,5.219
-13,20210718,Viridis2,1,A,5.84
-13,20210701,Ours,1,A,6.156
-13,20210729,Rainbow2,1,C,7.027
-13,20210718,Ours,1,C,4.238
-13,20210709,Rainbow,1,D,6.417
-13,20210718,Rainbow,1,B,9.472
-13,20210729,OMC,1,B,10.024
-13,20210701,OMC,1,C,5.596
-13,20210709,Viridis,1,C,6.473
-13,20210729,Rainbow,1,A,9.728
-13,20210701,Viridis2,1,A,4.875
-13,20210701,Rainbow2,1,A,6.932
-13,20210709,Rainbow2,1,B,4.491
-14,20210701,Viridis,1,A,4.29
-14,20210701,Rainbow,1,C,18.746
-14,20210709,Ours,1,B,8.396
-14,20210729,Ours,1,D,4.569
-14,20210718,Rainbow2,1,B,4.496
-14,20210709,Viridis2,1,B,6.266
-14,20210709,OMC,1,B,8.714
-14,20210729,Viridis,1,B,8.441
-14,20210718,Viridis,1,B,7.527
-14,20210718,OMC,1,D,11.182
-14,20210729,Viridis2,1,C,6.414
-14,20210718,Viridis2,1,A,5.445
-14,20210701,Ours,1,A,5.56
-14,20210729,Rainbow2,1,C,3.622
-14,20210718,Ours,1,C,4.784
-14,20210709,Rainbow,1,D,3.293
-14,20210718,Rainbow,1,C,3.686
-14,20210729,OMC,1,B,5.738
-14,20210701,OMC,1,D,3.275
-14,20210709,Viridis,1,B,9.199
-14,20210729,Rainbow,1,A,4.907
-14,20210701,Viridis2,1,A,6.75
-14,20210701,Rainbow2,1,B,6.849
-14,20210709,Rainbow2,1,B,4.067
-15,20210701,Viridis,1,A,14.593
-15,20210701,Rainbow,1,B,17.998
-15,20210709,Ours,1,B,9.284
-15,20210729,Ours,1,D,10.175
-15,20210718,Rainbow2,1,B,15.398
-15,20210709,Viridis2,1,D,16.358
-15,20210709,OMC,1,C,13.318
-15,20210729,Viridis,1,C,15.681
-15,20210718,Viridis,1,D,5.562
-15,20210718,OMC,1,C,2.147
-15,20210729,Viridis2,1,C,11.64
-15,20210718,Viridis2,1,A,17.623
-15,20210701,Ours,1,A,10.726
-15,20210729,Rainbow2,1,D,20.791
-15,20210718,Ours,1,C,24.415
-15,20210709,Rainbow,1,D,15.612
-15,20210718,Rainbow,1,D,87.383
-15,20210729,OMC,1,B,8.089
-15,20210701,OMC,1,C,8.324
-15,20210709,Viridis,1,C,10.053
-15,20210729,Rainbow,1,D,14.038
-15,20210701,Viridis2,1,A,5.998
-15,20210701,Rainbow2,1,A,9.862
-15,20210709,Rainbow2,1,B,8.141
-16,20210701,Viridis,1,A,65.88
-16,20210701,Rainbow,1,C,14.923
-16,20210709,Ours,1,B,19.551
-16,20210729,Ours,1,D,28.327
-16,20210718,Rainbow2,1,B,11.012
-16,20210709,Viridis2,1,B,11.826
-16,20210709,OMC,1,B,22.451
-16,20210729,Viridis,1,C,20.028
-16,20210718,Viridis,1,B,15.365
-16,20210718,OMC,1,C,9.964
-16,20210729,Viridis2,1,C,10.908
-16,20210718,Viridis2,1,B,8.232
-16,20210701,Ours,1,A,9.899
-16,20210729,Rainbow2,1,C,9.904
-16,20210718,Ours,1,C,7.62
-16,20210709,Rainbow,1,D,9.286
-16,20210718,Rainbow,1,D,13.239
-16,20210729,OMC,1,B,10.145
-16,20210701,OMC,1,C,8.706
-16,20210709,Viridis,1,D,17.017
-16,20210729,Rainbow,1,B,21.648
-16,20210701,Viridis2,1,A,53.537
-16,20210701,Rainbow2,1,A,12.743
-16,20210709,Rainbow2,1,B,8.382
-17,20210701,Viridis,1,D,7.413
-17,20210701,Rainbow,1,D,141.856
-17,20210709,Ours,1,D,6.357
-17,20210729,Ours,1,A,8.256
-17,20210718,Rainbow2,1,D,23.989
-17,20210709,Viridis2,1,A,8.894
-17,20210709,OMC,1,D,4.801
-17,20210729,Viridis,1,A,5.022
-17,20210718,Viridis,1,B,6.123
-17,20210718,OMC,1,C,6.181
-17,20210729,Viridis2,1,C,9.979
-17,20210718,Viridis2,1,B,8.611
-17,20210701,Ours,1,B,10.142
-17,20210729,Rainbow2,1,A,12.764
-17,20210718,Ours,1,C,4.594
-17,20210709,Rainbow,1,C,7.711
-17,20210718,Rainbow,1,C,8.148
-17,20210729,OMC,1,A,4.667
-17,20210701,OMC,1,C,6.461
-17,20210709,Viridis,1,C,5.125
-17,20210729,Rainbow,1,B,5.155
-17,20210701,Viridis2,1,B,5.201
-17,20210701,Rainbow2,1,D,6.455
-17,20210709,Rainbow2,1,C,8.054
-18,20210701,Viridis,1,A,5.504
-18,20210701,Rainbow,1,C,6.325
-18,20210709,Ours,1,B,12.768
-18,20210729,Ours,1,D,31.566
-18,20210718,Rainbow2,1,B,85.359
-18,20210709,Viridis2,1,D,6.028
-18,20210709,OMC,1,D,7.229
-18,20210729,Viridis,1,C,7.353
-18,20210718,Viridis,1,B,7.133
-18,20210718,OMC,1,C,13.021
-18,20210729,Viridis2,1,C,6.708
-18,20210718,Viridis2,1,A,6.171
-18,20210701,Ours,1,A,6.988
-18,20210729,Rainbow2,1,C,4.472
-18,20210718,Ours,1,C,4.045
-18,20210709,Rainbow,1,D,6.408
-18,20210718,Rainbow,1,D,6.999
-18,20210729,OMC,1,B,6.261
-18,20210701,OMC,1,C,6.152
-18,20210709,Viridis,1,C,6.874
-18,20210729,Rainbow,1,A,4.771
-18,20210701,Viridis2,1,A,5.784
-18,20210701,Rainbow2,1,B,9.358
-18,20210709,Rainbow2,1,B,6.365
-19,20210701,Viridis,1,A,10.514
-19,20210701,Rainbow,1,C,18.451
-19,20210709,Ours,1,B,13.757
-19,20210729,Ours,1,D,48.095
-19,20210718,Rainbow2,1,B,20.665
-19,20210709,Viridis2,1,D,13.144
-19,20210709,OMC,1,D,29.641
-19,20210729,Viridis,1,C,12.871
-19,20210718,Viridis,1,B,19.162
-19,20210718,OMC,1,C,10.551
-19,20210729,Viridis2,1,C,9.34
-19,20210718,Viridis2,1,A,7.831
-19,20210701,Ours,1,A,23.586
-19,20210729,Rainbow2,1,C,20.831
-19,20210718,Ours,1,C,14.998
-19,20210709,Rainbow,1,D,8.297
-19,20210718,Rainbow,1,B,13.374
-19,20210729,OMC,1,B,8.766
-19,20210701,OMC,1,C,23.63
-19,20210709,Viridis,1,C,11.576
-19,20210729,Rainbow,1,A,36.174
-19,20210701,Viridis2,1,A,6
-19,20210701,Rainbow2,1,A,12.986
-19,20210709,Rainbow2,1,B,15.564
-20,20210701,Viridis,1,A,37.691
-20,20210701,Rainbow,1,C,20.636
-20,20210709,Ours,1,B,13.871
-20,20210729,Ours,1,D,19.062
-20,20210718,Rainbow2,1,B,20.948
-20,20210709,Viridis2,1,B,24.018
-20,20210709,OMC,1,D,65.467
-20,20210729,Viridis,1,B,22.272
-20,20210718,Viridis,1,B,17.004
-20,20210718,OMC,1,A,14.661
-20,20210729,Viridis2,1,C,17.432
-20,20210718,Viridis2,1,A,12.22
-20,20210701,Ours,1,A,22.559
-20,20210729,Rainbow2,1,C,13.542
-20,20210718,Ours,1,C,21.452
-20,20210709,Rainbow,1,D,20.684
-20,20210718,Rainbow,1,D,12.022
-20,20210729,OMC,1,C,10.95
-20,20210701,OMC,1,C,25.255
-20,20210709,Viridis,1,D,26.424
-20,20210729,Rainbow,1,D,20.157
-20,20210701,Viridis2,1,A,8.142
-20,20210701,Rainbow2,1,A,35.557
-20,20210709,Rainbow2,1,B,12.705
-21,20210701,Viridis,1,A,6.567
-21,20210701,Rainbow,1,C,3.779
-21,20210709,Ours,1,B,5.329
-21,20210729,Ours,1,D,6.076
-21,20210718,Rainbow2,1,B,5.763
-21,20210709,Viridis2,1,D,4.224
-21,20210709,OMC,1,C,4.288
-21,20210729,Viridis,1,C,5.491
-21,20210718,Viridis,1,B,4.426
-21,20210718,OMC,1,C,3.876
-21,20210729,Viridis2,1,C,7.433
-21,20210718,Viridis2,1,A,4.64
-21,20210701,Ours,1,A,4.631
-21,20210729,Rainbow2,1,C,5.413
-21,20210718,Ours,1,C,8.967
-21,20210709,Rainbow,1,D,4.074
-21,20210718,Rainbow,1,B,5.973
-21,20210729,OMC,1,C,6.68
-21,20210701,OMC,1,C,4.228
-21,20210709,Viridis,1,C,3.909
-21,20210729,Rainbow,1,D,4.531
-21,20210701,Viridis2,1,A,4.843
-21,20210701,Rainbow2,1,A,4.51
-21,20210709,Rainbow2,1,B,3.91
-22,20210701,Viridis,1,A,3.031
-22,20210701,Rainbow,1,C,5.367
-22,20210709,Ours,1,B,4.104
-22,20210729,Ours,1,D,4.239
-22,20210718,Rainbow2,1,B,3.367
-22,20210709,Viridis2,1,B,7.831
-22,20210709,OMC,1,B,3.776
-22,20210729,Viridis,1,C,4.224
-22,20210718,Viridis,1,C,3.4
-22,20210718,OMC,1,A,4.28
-22,20210729,Viridis2,1,C,6.376
-22,20210718,Viridis2,1,B,3.624
-22,20210701,Ours,1,A,4.151
-22,20210729,Rainbow2,1,A,6.928
-22,20210718,Ours,1,C,3.568
-22,20210709,Rainbow,1,D,3.256
-22,20210718,Rainbow,1,D,3.319
-22,20210729,OMC,1,C,4.024
-22,20210701,OMC,1,C,3.28
-22,20210709,Viridis,1,D,4.664
-22,20210729,Rainbow,1,A,3.159
-22,20210701,Viridis2,1,A,1.784
-22,20210701,Rainbow2,1,A,5.944
-22,20210709,Rainbow2,1,B,2.423
-23,20210701,Viridis,1,D,29.626
-23,20210701,Rainbow,1,A,5.969
-23,20210709,Ours,1,A,4.039
-23,20210729,Ours,1,D,6.213
-23,20210718,Rainbow2,1,D,5.765
-23,20210709,Viridis2,1,A,4.351
-23,20210709,OMC,1,A,4.576
-23,20210729,Viridis,1,A,4.39
-23,20210718,Viridis,1,B,3.393
-23,20210718,OMC,1,C,3.483
-23,20210729,Viridis2,1,D,9.263
-23,20210718,Viridis2,1,B,6.598
-23,20210701,Ours,1,B,31.378
-23,20210729,Rainbow2,1,A,2.371
-23,20210718,Ours,1,C,4.679
-23,20210709,Rainbow,1,A,5.185
-23,20210718,Rainbow,1,D,8.503
-23,20210729,OMC,1,A,4.672
-23,20210701,OMC,1,C,4.492
-23,20210709,Viridis,1,C,2.3
-23,20210729,Rainbow,1,A,4.269
-23,20210701,Viridis2,1,B,4.738
-23,20210701,Rainbow2,1,D,5.145
-23,20210709,Rainbow2,1,A,6.87
-24,20210701,Viridis,1,A,4.222
-24,20210701,Rainbow,1,C,4.497
-24,20210709,Ours,1,B,5.011
-24,20210729,Ours,1,D,5.221
-24,20210718,Rainbow2,1,B,2.746
-24,20210709,Viridis2,1,B,4.159
-24,20210709,OMC,1,B,3.17
-24,20210729,Viridis,1,B,5.741
-24,20210718,Viridis,1,B,4.234
-24,20210718,OMC,1,C,4.741
-24,20210729,Viridis2,1,C,2.918
-24,20210718,Viridis2,1,A,3.2
-24,20210701,Ours,1,A,4.345
-24,20210729,Rainbow2,1,C,5.426
-24,20210718,Ours,1,C,4.143
-24,20210709,Rainbow,1,D,4.938
-24,20210718,Rainbow,1,D,6.916
-24,20210729,OMC,1,B,9.355
-24,20210701,OMC,1,C,4.078
-24,20210709,Viridis,1,C,3.096
-24,20210729,Rainbow,1,B,3.472
-24,20210701,Viridis2,1,A,4.439
-24,20210701,Rainbow2,1,A,4.89
-24,20210709,Rainbow2,1,B,3.127
-25,20210701,Viridis,1,C,4.671
-25,20210701,Rainbow,1,A,2.597
-25,20210709,Ours,1,A,3.048
-25,20210729,Ours,1,D,3.43
-25,20210718,Rainbow2,1,D,3.89
-25,20210709,Viridis2,1,A,3.425
-25,20210709,OMC,1,C,3.122
-25,20210729,Viridis,1,A,89.598
-25,20210718,Viridis,1,B,2.661
-25,20210718,OMC,1,C,1.997
-25,20210729,Viridis2,1,D,4.386
-25,20210718,Viridis2,1,B,3.311
-25,20210701,Ours,1,B,9.417
-25,20210729,Rainbow2,1,A,2.114
-25,20210718,Ours,1,C,8.553
-25,20210709,Rainbow,1,A,1.934
-25,20210718,Rainbow,1,D,2.178
-25,20210729,OMC,1,C,3.723
-25,20210701,OMC,1,C,3.287
-25,20210709,Viridis,1,C,3.942
-25,20210729,Rainbow,1,B,2.558
-25,20210701,Viridis2,1,B,4.359
-25,20210701,Rainbow2,1,D,3.83
-25,20210709,Rainbow2,1,C,3.002
-26,20210701,Viridis,1,A,11.924
-26,20210701,Rainbow,1,C,51.611
-26,20210709,Ours,1,B,16.746
-26,20210729,Ours,1,D,14.022
-26,20210718,Rainbow2,1,B,11.315
-26,20210709,Viridis2,1,D,26.401
-26,20210709,OMC,1,B,15.018
-26,20210729,Viridis,1,C,18.166
-26,20210718,Viridis,1,B,12.77
-26,20210718,OMC,1,C,10.331
-26,20210729,Viridis2,1,C,8.4
-26,20210718,Viridis2,1,A,23.515
-26,20210701,Ours,1,A,9.429
-26,20210729,Rainbow2,1,C,14.103
-26,20210718,Ours,1,C,7.412
-26,20210709,Rainbow,1,D,10.238
-26,20210718,Rainbow,1,C,12.322
-26,20210729,OMC,1,B,13.568
-26,20210701,OMC,1,C,11.408
-26,20210709,Viridis,1,B,10.992
-26,20210729,Rainbow,1,D,11.66
-26,20210701,Viridis2,1,A,8.082
-26,20210701,Rainbow2,1,A,8.589
-26,20210709,Rainbow2,1,B,8.927
-27,20210701,Viridis,1,A,10.245
-27,20210701,Rainbow,1,B,17.591
-27,20210709,Ours,1,B,27.963
-27,20210729,Ours,1,D,13.595
-27,20210718,Rainbow2,1,B,8.508
-27,20210709,Viridis2,1,D,14.138
-27,20210709,OMC,1,D,52.991
-27,20210729,Viridis,1,C,20.658
-27,20210718,Viridis,1,D,16.037
-27,20210718,OMC,1,C,12.989
-27,20210729,Viridis2,1,C,11.437
-27,20210718,Viridis2,1,C,16.313
-27,20210701,Ours,1,A,11.053
-27,20210729,Rainbow2,1,C,27.077
-27,20210718,Ours,1,C,12.011
-27,20210709,Rainbow,1,D,10.435
-27,20210718,Rainbow,1,B,9.133
-27,20210729,OMC,1,B,27.926
-27,20210701,OMC,1,C,13.51
-27,20210709,Viridis,1,A,32.523
-27,20210729,Rainbow,1,B,27.579
-27,20210701,Viridis2,1,A,9.907
-27,20210701,Rainbow2,1,A,11.231
-27,20210709,Rainbow2,1,B,9.987
-28,20210701,Viridis,1,A,10.388
-28,20210701,Rainbow,1,C,7.225
-28,20210709,Ours,1,B,26.316
-28,20210729,Ours,1,D,11.37
-28,20210718,Rainbow2,1,B,8.07
-28,20210709,Viridis2,1,D,19.636
-28,20210709,OMC,1,D,16.048
-28,20210729,Viridis,1,B,17.428
-28,20210718,Viridis,1,B,8.902
-28,20210718,OMC,1,D,11.075
-28,20210729,Viridis2,1,C,12.945
-28,20210718,Viridis2,1,A,7.125
-28,20210701,Ours,1,A,9.5
-28,20210729,Rainbow2,1,C,12.255
-28,20210718,Ours,1,C,21.505
-28,20210709,Rainbow,1,D,10.71
-28,20210718,Rainbow,1,D,13.944
-28,20210729,OMC,1,B,11.15
-28,20210701,OMC,1,C,9.888
-28,20210709,Viridis,1,B,10.056
-28,20210729,Rainbow,1,B,12.565
-28,20210701,Viridis2,1,A,7
-28,20210701,Rainbow2,1,B,14.29
-28,20210709,Rainbow2,1,B,9.728
-29,20210701,Viridis,1,A,3.594
-29,20210701,Rainbow,1,C,6.985
-29,20210709,Ours,1,B,4.843
-29,20210729,Ours,1,D,4.246
-29,20210718,Rainbow2,1,B,3.278
-29,20210709,Viridis2,1,B,7.768
-29,20210709,OMC,1,B,6.804
-29,20210729,Viridis,1,B,8.839
-29,20210718,Viridis,1,B,4.929
-29,20210718,OMC,1,C,8.002
-29,20210729,Viridis2,1,C,4.016
-29,20210718,Viridis2,1,A,3.905
-29,20210701,Ours,1,A,4.243
-29,20210729,Rainbow2,1,C,4.228
-29,20210718,Ours,1,C,3.615
-29,20210709,Rainbow,1,D,3.732
-29,20210718,Rainbow,1,D,3.45
-29,20210729,OMC,1,C,4.891
-29,20210701,OMC,1,C,3.806
-29,20210709,Viridis,1,C,6.768
-29,20210729,Rainbow,1,A,4.042
-29,20210701,Viridis2,1,A,3.548
-29,20210701,Rainbow2,1,A,4.4
-29,20210709,Rainbow2,1,B,2.63
-30,20210701,Viridis,1,A,4.003
-30,20210701,Rainbow,1,C,8.963
-30,20210709,Ours,1,B,12.175
-30,20210729,Ours,1,D,20.987
-30,20210718,Rainbow2,1,B,4.374
-30,20210709,Viridis2,1,B,90.811
-30,20210709,OMC,1,B,9.729
-30,20210729,Viridis,1,C,11.327
-30,20210718,Viridis,1,B,12.437
-30,20210718,OMC,1,C,16.819
-30,20210729,Viridis2,1,C,13.049
-30,20210718,Viridis2,1,A,16.26
-30,20210701,Ours,1,A,8.607
-30,20210729,Rainbow2,1,C,8.438
-30,20210718,Ours,1,C,25.823
-30,20210709,Rainbow,1,D,20.011
-30,20210718,Rainbow,1,C,7.901
-30,20210729,OMC,1,B,7.721
-30,20210701,OMC,1,C,7.575
-30,20210709,Viridis,1,C,5.869
-30,20210729,Rainbow,1,D,13.201
-30,20210701,Viridis2,1,A,6.858
-30,20210701,Rainbow2,1,A,5.467
-30,20210709,Rainbow2,1,B,6.633
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 9d2efea..7eda96b 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -129,14 +129,19 @@ Maps column name → semantic type. This drives encoding type, formatting, aggre
interface ChartEncoding {
field?: string;
type?: 'quantitative' | 'nominal' | 'ordinal' | 'temporal';
- aggregate?: 'count' | 'sum' | 'average';
+ aggregate?: 'count' | 'sum' | 'average' | 'mean';
sortOrder?: 'ascending' | 'descending';
sortBy?: string;
scheme?: string;
}
```
-Explicit `type` overrides semantic inference. Explicit `aggregate` overrides auto-aggregation when auto-aggregation is enabled.
+Explicit `type` overrides semantic inference. Setting `aggregate` asks Flint to
+collapse the rows itself — grouping by the other (non-aggregated) field channels
+and producing a derived column named `${field}_${aggregate}` (`count` →
+`_count`). `average` and `mean` are synonyms. Most callers should still
+aggregate their data upstream; if you do, omit `aggregate` and reference the
+derived column by name.
Common channels: `x`, `y`, `color`, `size`, `shape`, `column`, `row`, `group`, `detail`.
diff --git a/docs/overview.md b/docs/overview.md
index 44e1a88..e1a4490 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -170,7 +170,7 @@ const spec = assembleVegaLite({
| Page | Use for |
|------|---------|
| [Getting started](/documentation/getting-started) | Step-by-step first chart |
-| [Set up Flint MCP](/documentation/setup-flint-mcp) | MCP server setup, data roots, tools, and verification |
+| [Set up Flint MCP](/documentation/setup-flint-mcp) | MCP server setup, file access, tools, and verification |
| [Agent workflows](/documentation/agent-workflows) | Custom agent and product integration patterns |
| [Gallery](/gallery) | Every template + multi-backend preview |
| [Editor](/editor) | Paste JSON, switch Vega-Lite / ECharts / Chart.js |
diff --git a/docs/tutorials/setup-flint-mcp.md b/docs/tutorials/setup-flint-mcp.md
index cca9378..fec53d7 100644
--- a/docs/tutorials/setup-flint-mcp.md
+++ b/docs/tutorials/setup-flint-mcp.md
@@ -41,11 +41,11 @@ You need:
- an MCP client that can run stdio servers;
- Node.js and npm available to that client;
-- chart data either embedded directly in the tool call or stored in an allowed
- local data root.
+- chart data either embedded directly in the tool call or read from a local
+ file on the host.
-The server renders in-process on the host machine. Inline rows and allowed local
-files stay local; the server does not upload data to a remote rendering service.
+The server renders in-process on the host machine. Inline rows and local files
+stay local; the server does not upload data to a remote rendering service.
## Run with `npx`
@@ -76,7 +76,9 @@ For VS Code, add a server entry in `.vscode/mcp.json`:
```
If the agent should chart local `.csv`, `.tsv`, or `.json` files by `data.url`,
-grant an explicit data root:
+it can do so by default. To harden an untrusted deployment, reject local file
+references entirely with `--disable-file-reference` (the agent must then pass
+rows inline via `data.values`):
```jsonc
{
@@ -84,7 +86,7 @@ grant an explicit data root:
"flint": {
"type": "stdio",
"command": "npx",
- "args": ["-y", "flint-chart-mcp", "--data-roots", "./data"]
+ "args": ["-y", "flint-chart-mcp", "--disable-file-reference"]
}
}
}
@@ -109,32 +111,33 @@ Many MCP clients use an `mcpServers` object:
}
```
-With local file access enabled:
+To disable local file reads entirely (inline `data.values` only):
```jsonc
{
"mcpServers": {
"flint": {
"command": "npx",
- "args": ["-y", "flint-chart-mcp", "--data-roots", "./data"]
+ "args": ["-y", "flint-chart-mcp", "--disable-file-reference"]
}
}
}
```
-Use an absolute path for the data root if your client starts servers from a
-different working directory than your project.
-
## Run from this repository
-When developing Flint itself, build the MCP package and point the client at the
-local CLI:
+When developing Flint itself, build the packages and point the client at the
+local CLI. The MCP package depends on `flint-chart`, so build both (the root
+`build` script builds `flint-js` first, then `flint-mcp`):
```bash
npm install
-npm --prefix packages/flint-mcp run build
+npm run build
```
+To rebuild only the MCP package after the library is already built, run
+`npm run build:mcp`.
+
VS Code local-source config:
```jsonc
@@ -144,9 +147,7 @@ VS Code local-source config:
"type": "stdio",
"command": "node",
"args": [
- "${workspaceFolder}/packages/flint-mcp/dist/cli.js",
- "--data-roots",
- "${workspaceFolder}/shared/test-data"
+ "${workspaceFolder}/packages/flint-mcp/dist/cli.js"
]
}
}
@@ -163,17 +164,19 @@ MCP tool calls can bind data in two ways:
- **Embedded rows:** pass `data: { values: [...] }` directly in the tool call.
This is the simplest path for small or already prepared tables.
- **Local file references:** pass `data: { url: "..." }` for a `.json`, `.csv`,
- or `.tsv` file under a configured data root.
+ or `.tsv` file on the host.
-Local file reads are intentionally restricted. Files outside the configured data
-roots are rejected, and remote URLs are not fetched.
+By default the server trusts the host and reads any local file the agent
+references (relative paths resolve against the working directory); the agent
+could already inline the same rows. Remote URLs are never fetched.
-Useful data-root options:
+For untrusted deployments, reject local file references entirely with
+`--disable-file-reference`. The agent must then pass rows inline via
+`data.values`:
```bash
-npx -y flint-chart-mcp --data-roots ./data,./fixtures
-npx -y flint-chart-mcp --data-root ./data --data-root ./fixtures
-FLINT_MCP_DATA_ROOTS=./data,./fixtures npx -y flint-chart-mcp
+npx -y flint-chart-mcp --disable-file-reference
+FLINT_MCP_DISABLE_FILE_REFERENCE=1 npx -y flint-chart-mcp
```
If the chart request needs aggregation, filtering, joins, pivots, derived
@@ -219,8 +222,9 @@ Use region as Category and revenue as Quantity.
Open it with create_chart_view if this client supports MCP Apps; otherwise render an SVG.
```
-If `list_chart_types` works but a local file chart fails, check the configured
-data root first. If `create_chart_view` is unavailable, the host likely does not
+If `list_chart_types` works but a local file chart fails, check that the file
+path is correct and that `--disable-file-reference` is not set. If
+`create_chart_view` is unavailable, the host likely does not
support MCP Apps; ask the agent to use `render_chart` instead.
## Next steps
diff --git a/package-lock.json b/package-lock.json
index b4a7233..7e0bc3e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9911,7 +9911,7 @@
},
"packages/flint-js": {
"name": "flint-chart",
- "version": "0.1.2",
+ "version": "0.1.3",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.14.10",
@@ -9952,7 +9952,7 @@
},
"packages/flint-mcp": {
"name": "flint-chart-mcp",
- "version": "0.1.2",
+ "version": "0.1.3",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.7.4",
diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json
index a6962e6..f4678db 100644
--- a/packages/flint-js/package.json
+++ b/packages/flint-js/package.json
@@ -1,6 +1,6 @@
{
"name": "flint-chart",
- "version": "0.1.2",
+ "version": "0.1.3",
"description": "Semantic-level visualization library that compiles data + semantic types into chart specs for Vega-Lite, ECharts, and Chart.js.",
"keywords": [
"visualization",
diff --git a/packages/flint-js/src/README.md b/packages/flint-js/src/README.md
index b23f5d8..da7b968 100644
--- a/packages/flint-js/src/README.md
+++ b/packages/flint-js/src/README.md
@@ -191,7 +191,7 @@ interface ChartAssemblyInput {
interface ChartEncoding {
field?: string;
type?: 'quantitative' | 'nominal' | 'ordinal' | 'temporal';
- aggregate?: 'count' | 'sum' | 'average';
+ aggregate?: 'count' | 'sum' | 'average' | 'mean';
sortOrder?: 'ascending' | 'descending';
sortBy?: string;
scheme?: string;
diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts
index e075d76..f97bbce 100644
--- a/packages/flint-js/src/chartjs/assemble.ts
+++ b/packages/flint-js/src/chartjs/assemble.ts
@@ -33,6 +33,7 @@ import {
} from '../core/types';
import type { ChartWarning } from '../core/types';
import { applyEncodingOverrides } from '../core/encoding-overrides';
+import { applyAggregation } from '../core/aggregate';
import { applyPivot, PivotSurface } from '../core/pivot';
import { cjsGetTemplateDef } from './templates';
import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics';
@@ -86,7 +87,7 @@ export function assembleChartjs(input: ChartAssemblyInput): any {
const normalized = normalizeStaticSeries(
input.chart_spec.encodings, rawData, semanticTypes,
);
- const data = normalized.data;
+ let data = normalized.data;
const staticSeries = normalized.staticSeries;
const prelimConvertedData = convertTemporalData(data, semanticTypes);
@@ -112,6 +113,9 @@ export function assembleChartjs(input: ChartAssemblyInput): any {
// the override value. See applyEncodingOverrides / EncodingActionDef.
const encodings = applyEncodingOverrides(chartTemplate, pivoted.encodings, chartProperties);
+ // Optional aggregation transform — see vegalite/assemble for rationale.
+ data = applyAggregation(encodings, data);
+
// ═══════════════════════════════════════════════════════════════════════
// PHASE 0: Resolve Semantics (shared with VL + EC — completely target-agnostic)
// ═══════════════════════════════════════════════════════════════════════
diff --git a/packages/flint-js/src/core/aggregate.ts b/packages/flint-js/src/core/aggregate.ts
new file mode 100644
index 0000000..8fa59e2
--- /dev/null
+++ b/packages/flint-js/src/core/aggregate.ts
@@ -0,0 +1,116 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Optional data aggregation transform.
+ *
+ * Flint's default contract is "callers own the data" — the host passes in rows
+ * that are already shaped for the chart. As a convenience, an encoding may set
+ * `aggregate` to ask Flint to collapse the rows itself, mirroring the way
+ * Vega-Lite derives an aggregated field:
+ *
+ * - Rows are grouped by every channel that has a `field` and no `aggregate`
+ * (the dimensions).
+ * - For each group, each aggregated channel produces a derived column named
+ * `${field}_${op}` (`count` produces `_count`), which the backend assemblers
+ * already reference once `aggregate` is set.
+ *
+ * The derived column name IS the contract: if a caller has already
+ * pre-aggregated, they simply reference that column by name (e.g. `revenue_sum`)
+ * and omit `aggregate`, and this transform is a no-op. `average` and `mean` are
+ * synonyms (both arithmetic mean) and keep distinct suffixes by design.
+ *
+ * This is a deliberate, opt-in exception to the no-transform principle — most
+ * callers should still aggregate upstream.
+ */
+
+import { ChartEncoding } from './types';
+
+interface AggSpec {
+ field?: string;
+ op: string;
+ /** Derived column name the assemblers expect (`${field}_${op}` or `_count`). */
+ target: string;
+}
+
+/**
+ * Apply requested `aggregate` operations to the data, returning grouped rows.
+ *
+ * Returns the input unchanged when no encoding requests aggregation, or when the
+ * derived columns are already present (caller pre-aggregated).
+ */
+export function applyAggregation(
+ encodings: Record,
+ data: any[],
+): any[] {
+ if (!data || data.length === 0) return data;
+
+ // Collect aggregate requests from the input encodings.
+ const specs: AggSpec[] = [];
+ for (const enc of Object.values(encodings)) {
+ if (!enc || !enc.aggregate) continue;
+ const op = enc.aggregate;
+ if (op !== 'count' && !enc.field) continue; // nothing to reduce
+ const target = op === 'count' ? '_count' : `${enc.field}_${op}`;
+ specs.push({ field: enc.field, op, target });
+ }
+ if (specs.length === 0) return data;
+
+ // If every derived column already exists, the caller pre-aggregated — trust
+ // the supplied data and do nothing.
+ const firstRow = data[0];
+ const allPresent = specs.every(s =>
+ Object.prototype.hasOwnProperty.call(firstRow, s.target),
+ );
+ if (allPresent) return data;
+
+ // Group-by dimensions: every channel with a field and no aggregate.
+ const groupFields: string[] = [];
+ const seen = new Set();
+ for (const enc of Object.values(encodings)) {
+ if (!enc || enc.aggregate || !enc.field) continue;
+ if (seen.has(enc.field)) continue;
+ seen.add(enc.field);
+ groupFields.push(enc.field);
+ }
+
+ // Bucket rows by the tuple of group-field values (insertion order preserved).
+ const groups = new Map();
+ for (const row of data) {
+ const key = JSON.stringify(groupFields.map(f => row[f] ?? null));
+ let bucket = groups.get(key);
+ if (!bucket) {
+ bucket = [];
+ groups.set(key, bucket);
+ }
+ bucket.push(row);
+ }
+
+ const toNum = (v: any): number => (typeof v === 'number' ? v : Number(v));
+ const reduceOp = (rows: any[], spec: AggSpec): number => {
+ if (spec.op === 'count') return rows.length;
+ const nums = rows
+ .map(r => toNum(r[spec.field as string]))
+ .filter(v => Number.isFinite(v));
+ if (nums.length === 0) return 0;
+ const sum = nums.reduce((a, b) => a + b, 0);
+ // 'average' and 'mean' are synonyms (arithmetic mean); 'sum' totals.
+ return spec.op === 'sum' ? sum : sum / nums.length;
+ };
+
+ const out: any[] = [];
+ for (const rows of groups.values()) {
+ const head = rows[0];
+ const aggregated: Record = {};
+ for (const f of groupFields) aggregated[f] = head[f];
+ for (const spec of specs) {
+ const val = reduceOp(rows, spec);
+ aggregated[spec.target] = val;
+ // Keep the source column populated so semantic/format inference for
+ // the measure channel still sees representative numeric values.
+ if (spec.op !== 'count' && spec.field) aggregated[spec.field] = val;
+ }
+ out.push(aggregated);
+ }
+ return out;
+}
diff --git a/packages/flint-js/src/core/resolve-semantics.ts b/packages/flint-js/src/core/resolve-semantics.ts
index 4f651c4..2490a9d 100644
--- a/packages/flint-js/src/core/resolve-semantics.ts
+++ b/packages/flint-js/src/core/resolve-semantics.ts
@@ -398,7 +398,8 @@ export function resolveChannelSemantics(
stackable,
};
- // Adjust field name for aggregated fields
+ // Adjust field name for aggregated fields (the derived column is either
+ // computed by applyAggregation or supplied pre-aggregated by the caller)
if (encoding.aggregate) {
if (encoding.aggregate === 'count') {
cs.field = '_count';
diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts
index f4d7953..1408210 100644
--- a/packages/flint-js/src/core/types.ts
+++ b/packages/flint-js/src/core/types.ts
@@ -45,7 +45,7 @@ export const channelGroups: Record = {
export interface ChartEncoding {
field?: string;
type?: "quantitative" | "nominal" | "ordinal" | "temporal";
- aggregate?: 'count' | 'sum' | 'average';
+ aggregate?: 'count' | 'sum' | 'average' | 'mean';
sortOrder?: "ascending" | "descending";
sortBy?: string;
scheme?: string;
@@ -948,8 +948,8 @@ export interface ChartAssemblyInput {
* - `{ values: any[] }` — an array of row objects (like Vega-Lite `data.values`).
* - `{ url: string }` — a URL or path reference to JSON/CSV data.
* Hosts that need local semantic/layout decisions should resolve this to
- * rows before assembly. The MCP renderer supports local JSON/CSV/TSV
- * files under configured data roots; it does not fetch remote URLs.
+ * rows before assembly. The MCP renderer reads local JSON/CSV/TSV
+ * files referenced by path; it does not fetch remote URLs.
*
* At least one of `values` or `url` must be provided.
*/
diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts
index 2aede27..b96d8e8 100644
--- a/packages/flint-js/src/echarts/assemble.ts
+++ b/packages/flint-js/src/echarts/assemble.ts
@@ -60,6 +60,7 @@ import {
} from '../core/types';
import type { ChartWarning } from '../core/types';
import { applyEncodingOverrides } from '../core/encoding-overrides';
+import { applyAggregation } from '../core/aggregate';
import { applyPivot, PivotSurface } from '../core/pivot';
import { ecGetTemplateDef } from './templates';
import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics';
@@ -118,7 +119,7 @@ export function assembleECharts(input: ChartAssemblyInput): any {
const normalized = normalizeStaticSeries(
input.chart_spec.encodings, rawData, semanticTypes,
);
- const data = normalized.data;
+ let data = normalized.data;
const staticSeries = normalized.staticSeries;
const prelimConvertedData = convertTemporalData(data, semanticTypes);
@@ -144,6 +145,9 @@ export function assembleECharts(input: ChartAssemblyInput): any {
// the override value. See applyEncodingOverrides / EncodingActionDef.
const encodings = applyEncodingOverrides(chartTemplate, pivoted.encodings, chartProperties);
+ // Optional aggregation transform — see vegalite/assemble for rationale.
+ data = applyAggregation(encodings, data);
+
// ═══════════════════════════════════════════════════════════════════════
// PHASE 0: Resolve Semantics (shared with VL — completely target-agnostic)
// ═══════════════════════════════════════════════════════════════════════
diff --git a/packages/flint-js/src/test-data/df-types.ts b/packages/flint-js/src/test-data/df-types.ts
index 9dc961e..1e7a745 100644
--- a/packages/flint-js/src/test-data/df-types.ts
+++ b/packages/flint-js/src/test-data/df-types.ts
@@ -41,7 +41,7 @@ export interface FieldItem {
tableRef: string;
}
-export type AggrOp = 'count' | 'sum' | 'average';
+export type AggrOp = 'count' | 'sum' | 'average' | 'mean';
export interface EncodingItem {
fieldID?: string;
diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts
index b60281f..cecc007 100644
--- a/packages/flint-js/src/vegalite/assemble.ts
+++ b/packages/flint-js/src/vegalite/assemble.ts
@@ -54,6 +54,7 @@ import {
} from '../core/types';
import type { ChartWarning, ChartOption, OptionEvalContext } from '../core/types';
import { applyEncodingOverrides } from '../core/encoding-overrides';
+import { applyAggregation } from '../core/aggregate';
import { applyPivot, type PivotSurface } from '../core/pivot';
import { vlGetTemplateDef } from './templates';
import { inferVisCategory, computeZeroDecision } from '../core/semantic-types';
@@ -130,7 +131,7 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
const normalized = normalizeStaticSeries(
input.chart_spec.encodings, rawData, semanticTypes,
);
- const data = normalized.data;
+ let data = normalized.data;
const staticSeries = normalized.staticSeries;
// Compose Category-B encoding-action overrides (stored by the host in
@@ -195,6 +196,12 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
? chartTemplate.normalizeEncodings(composedEncodings, data)
: composedEncodings;
+ // Optional aggregation transform: when an encoding sets `aggregate`, collapse
+ // the rows here (grouping by the dimension channels) so the derived
+ // `${field}_${op}` / `_count` columns the assemblers reference actually
+ // exist. No-op when no encoding requests it or the data is pre-aggregated.
+ data = applyAggregation(encodings, data);
+
// ═══════════════════════════════════════════════════════════════════════
// PHASE 0: Resolve Semantics (VL-free)
// ═══════════════════════════════════════════════════════════════════════
@@ -759,7 +766,10 @@ function buildVLEncodings(
}
}
- // Aggregation handling — data is always pre-aggregated
+ // Aggregation handling — point the encoding at the derived column.
+ // applyAggregation (run before semantics) produces `${field}_${op}`
+ // / `_count` when the caller requests it; a pre-aggregated caller
+ // supplies that column directly.
if (encoding.aggregate) {
if (encoding.aggregate === "count") {
encodingObj.field = "_count";
diff --git a/packages/flint-js/src/vegalite/templates/sparkline.ts b/packages/flint-js/src/vegalite/templates/sparkline.ts
index d020992..4757ca9 100644
--- a/packages/flint-js/src/vegalite/templates/sparkline.ts
+++ b/packages/flint-js/src/vegalite/templates/sparkline.ts
@@ -144,11 +144,15 @@ export const sparklineDef: ChartTemplateDef = {
const hasColor = !!enc.color?.field;
const baseline = (ctx.chartProperties?.baseline as string) ?? 'mean';
const useMedian = baseline === 'median';
- // Independent Y rescales each strip to its own range (shape-at-a-glance
- // for series on very different scales, e.g. stock prices); the default
- // shared scale keeps rows comparable by level. Applies to the trend
- // facet's per-row y resolution.
- const independentY = !!ctx.chartProperties?.independentYAxis;
+ // Per-strip independent Y is the default: each strip self-scales to its
+ // own range so the trace fills the band and sits centered next to its
+ // category label and value (Tufte's "dataword" — shape-at-a-glance). A
+ // shared scale instead pins every trace to one global range, which
+ // shoves low-level series to the strip floor (and high ones to the top),
+ // visibly misaligning them from their centered label/number. Set
+ // `independentYAxis: false` to opt back into the shared, level-comparable
+ // scale. Applies to the trend facet's per-row y resolution.
+ const independentY = ctx.chartProperties?.independentYAxis !== false;
// Guard: without both position fields there is no trend to draw; leave
// a valid single-line spec so assembly still succeeds.
@@ -222,12 +226,21 @@ export const sparklineDef: ChartTemplateDef = {
const facetRow = { field: facetField, type: 'nominal', sort: regions, header: null };
const lineMark = applyInterpolate({ type: 'line', strokeWidth: 1.5 }, ctx.chartProperties);
+ // Inset the trace's y range a couple px inside the strip. A faceted
+ // cell uses Vega `bounds: full`, so a line touching the top/bottom edge
+ // (plus its stroke) makes the cell render ~4px taller than its declared
+ // `height`. The text columns have no such overflow, so over many rows
+ // the trend column would drift out of step with its labels. Keeping the
+ // trace off the edges holds every column on the same row pitch.
+ const Y_INSET = 2;
+ const trendYScale = { range: [stripH - Y_INSET, Y_INSET] };
+
// ── Trend panel layers: the line plus an optional dashed reference.
const layers: any[] = [{
mark: lineMark,
encoding: {
x: { ...enc.x, axis: null },
- y: { ...enc.y, axis: null },
+ y: { ...enc.y, axis: null, scale: { ...(enc.y as any).scale, ...trendYScale } },
...(hasColor
? { color: { field: facetField, type: 'nominal', legend: null } }
: { color: { value: MONO_LINE } }),
@@ -267,8 +280,10 @@ export const sparklineDef: ChartTemplateDef = {
data: { values: trendData },
facet: { row: facetRow },
spec: { width: trendW, height: stripH, layer: layers },
- // Per-row y resolution: `independent` self-scales each strip;
- // `shared` (default) keeps every row on one comparable scale.
+ // Per-row y resolution: `independent` (default) self-scales each
+ // strip so its trace fills the band and aligns with its row label;
+ // `shared` (via `independentYAxis: false`) keeps every row on one
+ // comparable scale.
resolve: { scale: { y: independentY ? 'independent' : 'shared' } },
title: { text: trendTitle, anchor: 'middle', ...HEADER_STYLE },
};
diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts
index 1f44bbf..1684754 100644
--- a/packages/flint-js/tests/smoke.test.ts
+++ b/packages/flint-js/tests/smoke.test.ts
@@ -122,4 +122,49 @@ describe('public API smoke', () => {
expect(config.data.datasets[0].data).toHaveLength(1);
expect(config.data.datasets[1].data).toHaveLength(1);
});
+
+ it('aggregate encodings compute the derived field from raw rows (count/sum/average/mean)', () => {
+ const makeBar = (aggregate: 'count' | 'sum' | 'average' | 'mean') =>
+ assembleVegaLite({
+ data: {
+ // Raw, un-aggregated rows: method A has times [1, 3], method B has [2, 4].
+ values: [
+ { method: 'A', time: 1 },
+ { method: 'A', time: 3 },
+ { method: 'B', time: 2 },
+ { method: 'B', time: 4 },
+ ],
+ },
+ semantic_types: { method: 'Category', time: 'Quantity' },
+ chart_spec: {
+ chartType: 'Bar Chart',
+ encodings: {
+ x: { field: 'method' },
+ y: { field: 'time', aggregate },
+ },
+ },
+ }) as any;
+
+ // The encoding points at the derived column (`${field}_${aggregate}`; count
+ // uses `_count`) and the type is quantitative.
+ expect(makeBar('sum').encoding.y.field).toBe('time_sum');
+ expect(makeBar('average').encoding.y.field).toBe('time_average');
+ expect(makeBar('mean').encoding.y.field).toBe('time_mean');
+ expect(makeBar('count').encoding.y.field).toBe('_count');
+ for (const agg of ['sum', 'average', 'mean', 'count'] as const) {
+ expect(makeBar(agg).encoding.y.type).toBe('quantitative');
+ }
+
+ // Flint actually computes the aggregation: rows collapse to one per group
+ // (method A, method B) with the correct derived values.
+ const rowsFor = (agg: 'count' | 'sum' | 'average' | 'mean', col: string) =>
+ (makeBar(agg).data.values as any[])
+ .sort((a, b) => String(a.method).localeCompare(String(b.method)))
+ .map(r => r[col]);
+
+ expect(rowsFor('sum', 'time_sum')).toEqual([4, 6]); // A: 1+3, B: 2+4
+ expect(rowsFor('average', 'time_average')).toEqual([2, 3]); // A: mean(1,3), B: mean(2,4)
+ expect(rowsFor('mean', 'time_mean')).toEqual([2, 3]); // synonym of average
+ expect(rowsFor('count', '_count')).toEqual([2, 2]); // 2 rows per group
+ });
});
diff --git a/packages/flint-js/tests/sparkline.test.ts b/packages/flint-js/tests/sparkline.test.ts
index d122923..2d41bcb 100644
--- a/packages/flint-js/tests/sparkline.test.ts
+++ b/packages/flint-js/tests/sparkline.test.ts
@@ -106,14 +106,15 @@ describe('Vega-Lite Sparkline', () => {
expect(avgPanel(spec).spec.encoding.color.field).toBe('Metric');
});
- it('shares one y scale across all strips by default, but honors Independent Y', () => {
- const shared = assembleVegaLite(toInput(byTitle(cases, MULTI))) as any;
- // The trend facet owns the only real y scale; its rows share it by default.
+ it('self-scales each strip by default, but honors a shared Y when requested', () => {
+ const auto = assembleVegaLite(toInput(byTitle(cases, MULTI))) as any;
+ // Each strip owns its own y scale by default so the trace fills the band and
+ // lines up with its centered category label and value.
+ expect(trendPanel(auto).resolve?.scale?.y).toBe('independent');
+
+ const shared = assembleVegaLite(toInput(byTitle(cases, MULTI), { independentYAxis: false })) as any;
+ // Opting into a shared scale keeps every row comparable by absolute level.
expect(trendPanel(shared).resolve?.scale?.y).toBe('shared');
-
- const indep = assembleVegaLite(toInput(byTitle(cases, MULTI), { independentYAxis: true })) as any;
- // Independent Y self-scales each strip (per-row facet resolution).
- expect(trendPanel(indep).resolve?.scale?.y).toBe('independent');
});
it('honors the interpolate (curve) property', () => {
diff --git a/packages/flint-mcp/README.md b/packages/flint-mcp/README.md
index dff0f48..9dcae73 100644
--- a/packages/flint-mcp/README.md
+++ b/packages/flint-mcp/README.md
@@ -54,7 +54,20 @@ Data can be provided in two ways:
- **Embedded rows:** pass `data: { values: [...] }` directly in the MCP tool call.
- **Local file references:** pass `data: { url: "..." }` for a `.json`, `.csv`, or
- `.tsv` file under a configured data root. Remote URLs are not fetched.
+ `.tsv` file. Remote URLs are never fetched.
+
+By default the server **trusts the agent's host** for file access: any local file
+the agent references can be read (the host already governs what the agent may
+touch, and the agent can already inline the same rows via `data.values`).
+Relative `data.url` paths resolve against the server's working directory; absolute
+paths and `file://` URLs are read as given. For data it downloads or generates,
+the agent can simply create a folder in the project (e.g. `./flint-data`) and
+reference files from it — the server doesn't create or require any special
+directory.
+
+For untrusted/server deployments, pass `--disable-file-reference` to reject local
+`data.url` file references entirely, accepting only inline `data.values`. Reads
+stay read-only either way — the server never writes your data.
`backend` is one of `vegalite`, `echarts`, `chartjs`. The `chartjs` backend
renders **PNG only** (its engine has no SVG output).
@@ -80,15 +93,15 @@ npx -y flint-chart-mcp
}
```
-To let MCP tools render local files referenced by `data.url`, add an allowed
-data root:
+To **disable local file references** (accept only inline `data.values` — useful
+for untrusted/server deployments), pass `--disable-file-reference`:
```json
{
"mcpServers": {
"flint": {
"command": "npx",
- "args": ["-y", "flint-chart-mcp", "--data-roots", "./data"]
+ "args": ["-y", "flint-chart-mcp", "--disable-file-reference"]
}
}
}
@@ -103,9 +116,14 @@ Works with Claude Desktop, Cursor, VS Code, and any MCP client that speaks
--transport Transport (only "stdio" is supported). Default: stdio.
--backends Comma-separated subset of vegalite,echarts,chartjs.
Overridden by FLINT_MCP_BACKENDS if set.
---data-roots Comma-separated directories local data.url files may read.
- Overridden by FLINT_MCP_DATA_ROOTS if set.
---data-root Add one allowed local data root. May be repeated.
+--disable-file-reference
+ Reject local data.url file references; accept only inline
+ data.values. When unset, any local file the agent
+ references is readable (relative paths resolve against the
+ working directory). Also enabled by
+ FLINT_MCP_DISABLE_FILE_REFERENCE.
+--data-roots Deprecated and ignored. Local files are readable by default.
+--data-root Deprecated and ignored. Local files are readable by default.
-v, --version Print version.
-h, --help Print help.
```
@@ -116,10 +134,11 @@ Gate the exposed backends at deploy time:
FLINT_MCP_BACKENDS=vegalite,echarts npx -y flint-chart-mcp
```
-Allow local file references for MCP rendering:
+Local `data.url` files are readable by default. To harden an untrusted
+deployment, reject local file references and accept only inline rows:
```bash
-npx -y flint-chart-mcp --data-roots ./data
+npx -y flint-chart-mcp --disable-file-reference
```
## Example `render_chart` call
@@ -175,9 +194,12 @@ layout shows up in the artifact.
## Security & limits
- **No remote upload.** All rendering is in-process; data stays on the host.
-- **Explicit local data roots.** `data.url` can read local `.json`, `.csv`, or
- `.tsv` files only under configured roots. Remote URLs are disabled to avoid SSRF.
-- **DoS guards.** Row count and canvas dimensions are capped for hostile specs.
+- **Read-only file access.** `data.url` reads local `.json`, `.csv`, or `.tsv`
+ files only — the server never writes your data. By default it trusts the
+ agent's host for which files are readable (the agent could already inline the
+ same rows); use `--disable-file-reference` to reject local file references in
+ untrusted deployments. Remote URLs are disabled to avoid SSRF.
+- **DoS guards.** Row count, file size, and canvas dimensions are capped for hostile specs.
## Development
diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md
index 1786f23..216d0d4 100644
--- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md
+++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md
@@ -104,13 +104,15 @@ Use the binding mode that matches the runtime. Do not mix them.
data is small or already transformed by another tool, pass it as
`data: { values: [...] }`. Do not pass runtime variable names in
MCP tool calls — the MCP server cannot see your local variables.
-2. **Direct MCP rendering: reference a local file only when configured.**
+2. **Direct MCP rendering: reference a local file.**
The `flint-chart-mcp` server can load `data: { url: "..." }` from local
- `.json`, `.csv`, or `.tsv` files, but only under directories that the host
- explicitly allowed with `--data-roots`, `--data-root`, or
- `FLINT_MCP_DATA_ROOTS`. Remote URL fetching is disabled. If the data must be
- transformed first, use a coding/data tool to write a small prepared file
- under an allowed root, then reference that file.
+ `.json`, `.csv`, or `.tsv` files. By default any local file the agent can
+ name is readable (relative paths resolve against the working directory); a
+ hardened deployment may reject local file references entirely via
+ `--disable-file-reference` (or `FLINT_MCP_DISABLE_FILE_REFERENCE`), in which
+ case pass rows inline with `data.values`. Remote URL
+ fetching is disabled. If the data must be transformed first, use a
+ coding/data tool to write a small prepared file, then reference that file.
3. **Generated application or notebook code: bind runtime variables.** If the
user asks you to add Flint to code, write normal data-loading code first and
pass a real runtime value, e.g. `data: { values: rows }`, to
@@ -262,7 +264,7 @@ string shorthand, expanded to `{ field: "" }`):
|---|---|---|
| `field` | column name | Bind the channel to a data column |
| `type` | `quantitative`, `nominal`, `ordinal`, `temporal` | Override the inferred encoding type (rarely needed) |
-| `aggregate` | `count`, `sum`, `average` | Force an aggregation on a measure channel |
+| `aggregate` | `count`, `sum`, `average`, `mean` | Force an aggregation on a measure channel |
| `sortOrder` | `ascending`, `descending` | Sort direction for a discrete/sorted axis |
| `sortBy` | channel name (e.g. `"y"`) or field | Sort a category axis by another channel's measure |
| `scheme` | Vega scheme name (e.g. `viridis`, `redblue`) | Color scheme for the `color` channel |
diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json
index 00eb777..61bfe0f 100644
--- a/packages/flint-mcp/package.json
+++ b/packages/flint-mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "flint-chart-mcp",
- "version": "0.1.2",
+ "version": "0.1.3",
"description": "Model Context Protocol server for Flint — compile, validate, and render semantic chart specs to Vega-Lite, ECharts, or Chart.js artifacts (PNG/SVG) in-process.",
"keywords": [
"mcp",
diff --git a/packages/flint-mcp/src/cli.ts b/packages/flint-mcp/src/cli.ts
index 0695213..0436677 100644
--- a/packages/flint-mcp/src/cli.ts
+++ b/packages/flint-mcp/src/cli.ts
@@ -18,9 +18,14 @@ Options:
--backends Comma-separated backends to expose
(subset of: ${SUPPORTED_BACKENDS.join(', ')}).
Overridden by the FLINT_MCP_BACKENDS env var if set.
- --data-roots Comma-separated directories local data.url files may read.
- Overridden by the FLINT_MCP_DATA_ROOTS env var if set.
- --data-root Add one allowed local data root. May be repeated.
+ --disable-file-reference
+ Reject local data.url file references and accept only
+ inline data.values. By default any local file the agent
+ references can be read (relative paths resolve against
+ the working directory). Also enabled by the
+ FLINT_MCP_DISABLE_FILE_REFERENCE env var.
+ --data-roots Deprecated and ignored. Local files are readable by default.
+ --data-root Deprecated and ignored. Local files are readable by default.
-v, --version Print version and exit.
-h, --help Print this help and exit.
@@ -40,7 +45,10 @@ Example MCP client config:
interface ParsedArgs {
transport: string;
backends?: SupportedBackend[];
- dataRoots?: string[];
+ /** When true, reject local data.url file references (inline rows only). */
+ disableFileReference: boolean;
+ /** True when a deprecated --data-root(s) flag was passed (ignored, warned). */
+ usedDeprecatedDataRoots: boolean;
}
function parseBackends(raw: string | undefined): SupportedBackend[] | undefined {
@@ -52,23 +60,20 @@ function parseBackends(raw: string | undefined): SupportedBackend[] | undefined
return list.length ? list : undefined;
}
-function parseDataRoots(raw: string | undefined): string[] | undefined {
- if (!raw) return undefined;
- const list = raw
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
- return list.length ? list : undefined;
-}
-
-function addDataRoot(out: ParsedArgs, rawRoot: string | undefined): void {
- const root = rawRoot?.trim();
- if (!root) return;
- out.dataRoots = [...(out.dataRoots ?? []), root];
+/** Parse a boolean env var; undefined when unset so the flag can win. */
+function parseBoolEnv(raw: string | undefined): boolean | undefined {
+ if (raw == null) return undefined;
+ const value = raw.trim().toLowerCase();
+ if (value === '') return undefined;
+ return value !== '0' && value !== 'false' && value !== 'no';
}
function parseArgs(argv: string[]): ParsedArgs {
- const out: ParsedArgs = { transport: 'stdio' };
+ const out: ParsedArgs = {
+ transport: 'stdio',
+ disableFileReference: false,
+ usedDeprecatedDataRoots: false,
+ };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
@@ -88,21 +93,22 @@ function parseArgs(argv: string[]): ParsedArgs {
case '--backends':
out.backends = parseBackends(argv[++i]);
break;
- case '--data-roots':
- out.dataRoots = parseDataRoots(argv[++i]);
+ case '--disable-file-reference':
+ out.disableFileReference = true;
break;
+ case '--data-roots':
case '--data-root':
- addDataRoot(out, argv[++i]);
+ // Deprecated: consume and ignore the value; warned about in main().
+ i++;
+ out.usedDeprecatedDataRoots = true;
break;
default:
if (arg.startsWith('--transport=')) {
out.transport = arg.slice('--transport='.length);
} else if (arg.startsWith('--backends=')) {
out.backends = parseBackends(arg.slice('--backends='.length));
- } else if (arg.startsWith('--data-roots=')) {
- out.dataRoots = parseDataRoots(arg.slice('--data-roots='.length));
- } else if (arg.startsWith('--data-root=')) {
- addDataRoot(out, arg.slice('--data-root='.length));
+ } else if (arg.startsWith('--data-roots=') || arg.startsWith('--data-root=')) {
+ out.usedDeprecatedDataRoots = true;
} else {
process.stderr.write(`Unknown argument: ${arg}\n`);
process.exit(2);
@@ -125,20 +131,37 @@ async function main(): Promise {
// Env var takes precedence over the flag for deployment-time gating.
const enabledBackends =
parseBackends(process.env.FLINT_MCP_BACKENDS) ?? args.backends;
- const dataRoots =
- parseDataRoots(process.env.FLINT_MCP_DATA_ROOTS) ?? args.dataRoots;
+ const envDisable = parseBoolEnv(process.env.FLINT_MCP_DISABLE_FILE_REFERENCE);
+ const disableFileReference = envDisable ?? args.disableFileReference;
+
+ // The legacy --data-roots/--data-root flags and FLINT_MCP_DATA_ROOTS env var
+ // are deprecated and no longer take effect. They USED to allow/whitelist local
+ // file reads, so we must NOT steer migrators toward --disable-file-reference
+ // (the opposite intent) — that would accidentally turn off all file charting.
+ if (args.usedDeprecatedDataRoots || process.env.FLINT_MCP_DATA_ROOTS?.trim()) {
+ process.stderr.write(
+ 'flint-chart-mcp: --data-roots / --data-root (and FLINT_MCP_DATA_ROOTS) are ' +
+ 'deprecated and have NO effect. Local data.url files are now readable by ' +
+ 'default, so you can safely REMOVE these flags and local-file charts keep ' +
+ 'working. (Only add --disable-file-reference if you instead want to BLOCK ' +
+ 'local file reads.)\n',
+ );
+ }
// Validate eagerly so a bad config fails fast with a clear message.
const resolved = resolveBackends({ enabledBackends });
- const server = createServer({ enabledBackends, dataRoots });
+ const server = createServer({ enabledBackends, disableFileReference });
const transport = new StdioServerTransport();
await server.connect(transport);
- // stdout is the protocol channel — log to stderr only.
+ // stdout is the protocol channel; log to stderr only.
+ const dataMode = disableFileReference
+ ? 'local file references disabled'
+ : 'local files readable on request';
process.stderr.write(
- `flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}` +
- `${dataRoots?.length ? `; data roots: ${dataRoots.join(', ')}` : ''})\n`,
+ `flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}; ` +
+ `${dataMode})\n`,
);
}
@@ -146,3 +169,4 @@ main().catch((err) => {
process.stderr.write(`flint-chart-mcp failed to start: ${err?.stack ?? err}\n`);
process.exit(1);
});
+
diff --git a/packages/flint-mcp/src/render/assemble.ts b/packages/flint-mcp/src/render/assemble.ts
index b33899c..0ab4d86 100644
--- a/packages/flint-mcp/src/render/assemble.ts
+++ b/packages/flint-mcp/src/render/assemble.ts
@@ -53,8 +53,8 @@ export interface AssembleResult {
/**
* Validate caller-supplied input before it reaches an assembler. Inline rows
- * pass through directly. Local `data.url` references are resolved only when the
- * server has explicit data roots; remote URLs stay blocked.
+ * pass through directly. Local `data.url` references are read unless
+ * `disableFileReference` is set; remote URLs stay blocked.
*/
export function validateInput(
input: ChartAssemblyInput,
@@ -82,7 +82,7 @@ export function prepareInput(
}
if (!Array.isArray(resolvedData.values)) {
throw new Error(
- 'input.data must provide inline values or a local data.url under configured data roots',
+ 'input.data must provide inline values or a readable local data.url',
);
}
if (resolvedData.values.length > MAX_DATA_ROWS) {
diff --git a/packages/flint-mcp/src/render/data-source.ts b/packages/flint-mcp/src/render/data-source.ts
index 7aeed6a..911eca5 100644
--- a/packages/flint-mcp/src/render/data-source.ts
+++ b/packages/flint-mcp/src/render/data-source.ts
@@ -2,12 +2,7 @@
// Licensed under the MIT License.
import { readFileSync, realpathSync, statSync } from 'node:fs';
-import {
- extname,
- isAbsolute,
- relative,
- resolve as resolvePath,
-} from 'node:path';
+import { extname, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ChartAssemblyInput } from 'flint-chart';
@@ -15,36 +10,27 @@ import type { ChartAssemblyInput } from 'flint-chart';
export const MAX_DATA_FILE_BYTES = 10 * 1024 * 1024;
export interface DataSourceOptions {
- /** Directories from which local data.url references may be read. */
- dataRoots?: readonly string[];
+ /**
+ * When true, local `data.url` file references are rejected; only inline
+ * `data.values` are accepted. By default the server trusts the host and reads
+ * any local file the agent references.
+ */
+ disableFileReference?: boolean;
/** File-size guard for local data references. */
maxDataFileBytes?: number;
/** Row-count guard after loading inline or referenced data. */
maxDataRows?: number;
}
-/** Resolve configured data roots to real absolute directories. */
-export function resolveDataRoots(dataRoots: readonly string[] | undefined): string[] {
- const resolvedRoots = new Set();
- for (const rawRoot of dataRoots ?? []) {
- const trimmedRoot = rawRoot.trim();
- if (!trimmedRoot) continue;
- const absoluteRoot = resolvePath(trimmedRoot);
- const stats = statSync(absoluteRoot);
- if (!stats.isDirectory()) {
- throw new Error(`data root is not a directory: ${rawRoot}`);
- }
- resolvedRoots.add(realpathSync(absoluteRoot));
- }
- return [...resolvedRoots];
-}
-
/**
* Resolve an input data reference for local MCP rendering.
*
- * Inline rows pass through unchanged. Local `data.url` references are loaded
- * into inline rows only when they are under an explicitly configured data root.
- * Remote URLs stay blocked.
+ * Inline rows pass through unchanged. Remote URLs stay blocked. Local
+ * `data.url` references are read into inline rows unless
+ * {@link DataSourceOptions.disableFileReference} is set, in which case they are
+ * rejected. By default the server trusts the host: any local file the agent can
+ * name is read (relative paths resolve against the working directory), since the
+ * host already governs the agent's file access.
*/
export function resolveDataSource(
input: ChartAssemblyInput,
@@ -71,19 +57,18 @@ export function resolveDataSource(
if (isRemoteReference(data.url)) {
throw new Error(
'remote data.url fetching is disabled in this server; use inline data.values ' +
- 'or a local file under --data-roots',
+ 'or a local file path',
);
}
- const dataRoots = resolveDataRoots(options.dataRoots);
- if (dataRoots.length === 0) {
+ if (options.disableFileReference) {
throw new Error(
- 'local data.url references require --data-roots, --data-root, or FLINT_MCP_DATA_ROOTS; ' +
- 'pass inline data.values otherwise',
+ 'local data.url file references are disabled on this server; ' +
+ 'pass the rows inline with data.values instead',
);
}
- const filePath = resolveLocalDataPath(data.url, dataRoots);
+ const filePath = resolveTrustedDataPath(data.url);
const rows = readLocalRows(filePath, options);
return { ...input, data: { values: rows } } as ChartAssemblyInput;
}
@@ -93,9 +78,13 @@ function isRemoteReference(rawUrl: string): boolean {
return new URL(rawUrl).protocol !== 'file:';
}
-function resolveLocalDataPath(rawUrl: string, dataRoots: readonly string[]): string {
- const rawReference = rawUrl.trim();
- const candidatePaths = referenceToPaths(rawReference, dataRoots);
+/**
+ * Resolve a local data.url. Any local file the agent can name is read — the host
+ * governs the agent's file access. Relative references resolve against the
+ * working directory.
+ */
+function resolveTrustedDataPath(rawUrl: string): string {
+ const candidatePaths = trustedReferenceToPaths(rawUrl.trim());
let lastError: unknown;
for (const candidatePath of candidatePaths) {
try {
@@ -103,44 +92,32 @@ function resolveLocalDataPath(rawUrl: string, dataRoots: readonly string[]): str
if (!stats.isFile()) {
throw new Error(`data.url must point to a file: ${rawUrl}`);
}
- const realCandidate = realpathSync(candidatePath);
- if (!dataRoots.some((root) => isPathInsideRoot(realCandidate, root))) {
- throw new Error('data.url is outside the configured data roots');
- }
- return realCandidate;
+ return realpathSync(candidatePath);
} catch (err) {
lastError = err;
}
}
-
if (lastError instanceof Error && !/no such file or directory/i.test(lastError.message)) {
throw lastError;
}
- throw new Error(`data.url file not found under configured data roots: ${rawUrl}`);
+ throw new Error(
+ `data.url local file not found: ${rawUrl} (looked in: ${candidatePaths.join(', ')})`,
+ );
}
-function referenceToPaths(rawReference: string, dataRoots: readonly string[]): string[] {
+function trustedReferenceToPaths(rawReference: string): string[] {
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(rawReference)) {
const parsedUrl = new URL(rawReference);
if (parsedUrl.protocol !== 'file:') {
throw new Error(
'remote data.url fetching is disabled in this server; use inline data.values ' +
- 'or a local file under --data-roots',
+ 'or a local file path',
);
}
return [fileURLToPath(parsedUrl)];
}
- return isAbsolute(rawReference)
- ? [rawReference]
- : dataRoots.map((root) => resolvePath(root, rawReference));
-}
-
-function isPathInsideRoot(candidatePath: string, rootPath: string): boolean {
- const relativePath = relative(rootPath, candidatePath);
- return (
- relativePath === '' ||
- (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath))
- );
+ // Absolute paths are used as given; relative paths resolve against cwd.
+ return [resolvePath(rawReference)];
}
function readLocalRows(
diff --git a/packages/flint-mcp/src/render/index.ts b/packages/flint-mcp/src/render/index.ts
index 0bdace3..f5ab60f 100644
--- a/packages/flint-mcp/src/render/index.ts
+++ b/packages/flint-mcp/src/render/index.ts
@@ -31,7 +31,6 @@ export {
} from './assemble.js';
export {
MAX_DATA_FILE_BYTES,
- resolveDataRoots,
resolveDataSource,
type DataSourceOptions,
} from './data-source.js';
@@ -71,7 +70,7 @@ export async function renderChart(
}
const { spec, warnings, width, height } = assembleForBackend(backend, input, {
- dataRoots: options.dataRoots,
+ disableFileReference: options.disableFileReference,
});
// Extract sizing before stripping Flint's private annotation keys. Vega-Lite
diff --git a/packages/flint-mcp/src/render/types.ts b/packages/flint-mcp/src/render/types.ts
index 5c155ad..4955a23 100644
--- a/packages/flint-mcp/src/render/types.ts
+++ b/packages/flint-mcp/src/render/types.ts
@@ -19,8 +19,8 @@ export interface RenderOptions {
scale?: number;
/** Background color for the artifact. Default: `#ffffff`. */
background?: string;
- /** Directories from which local `data.url` files may be read. */
- dataRoots?: readonly string[];
+ /** When true, reject local `data.url` file references (inline rows only). */
+ disableFileReference?: boolean;
}
/** A rendered artifact plus the assembly warnings that produced it. */
diff --git a/packages/flint-mcp/src/server.ts b/packages/flint-mcp/src/server.ts
index 63a8fb8..464fc2b 100644
--- a/packages/flint-mcp/src/server.ts
+++ b/packages/flint-mcp/src/server.ts
@@ -57,8 +57,12 @@ function readChartViewHtml(): string {
export interface CreateServerOptions {
/** Restrict which backends are exposed (default: all supported). */
enabledBackends?: SupportedBackend[];
- /** Directories from which local data.url references may be read. */
- dataRoots?: string[];
+ /**
+ * When true, reject local `data.url` file references and accept only inline
+ * `data.values`. When unset the server trusts the host and reads any local
+ * file the agent references.
+ */
+ disableFileReference?: boolean;
}
type JsonContent = { content: { type: 'text'; text: string }[]; isError?: boolean };
@@ -72,6 +76,26 @@ function errorResult(err: unknown): JsonContent {
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
}
+/**
+ * Build a short instruction note telling the agent how to reference local data,
+ * tailored to whether local file references are enabled.
+ */
+function dataAccessNote(options: CreateServerOptions): string {
+ if (options.disableFileReference) {
+ return (
+ ' Local data: local data.url file references are disabled on this server. ' +
+ 'Pass rows inline via data.values. Remote URLs are not fetched.'
+ );
+ }
+ return (
+ ' Local data: reference a local CSV/TSV/JSON file by path in data.url ' +
+ '(relative paths resolve against the working directory) or pass rows inline ' +
+ 'via data.values. For data you download or generate, create a folder in the ' +
+ 'current project (e.g. ./flint-data) and reference files from it. Remote ' +
+ 'URLs are not fetched.'
+ );
+}
+
/** Resolve and validate the set of enabled backends. */
export function resolveBackends(options: CreateServerOptions = {}): SupportedBackend[] {
const requested = options.enabledBackends?.length
@@ -94,7 +118,9 @@ export function resolveBackends(options: CreateServerOptions = {}): SupportedBac
*/
export function createServer(options: CreateServerOptions = {}): McpServer {
const backends = resolveBackends(options);
- const dataSourceOptions = { dataRoots: options.dataRoots };
+ const dataSourceOptions = {
+ disableFileReference: options.disableFileReference,
+ };
const backendEnum = z
.enum(backends as [SupportedBackend, ...SupportedBackend[]])
.describe(`Rendering backend. One of: ${backends.join(', ')}.`);
@@ -112,7 +138,8 @@ export function createServer(options: CreateServerOptions = {}): McpServer {
'static image. Use compile_chart for the backend spec JSON, ' +
'validate_chart to check a spec, and list_chart_types to discover chart ' +
'types and their channels. Before authoring specs, read the ' +
- 'flint://agent-skill resource or use the author_flint_chart prompt.',
+ 'flint://agent-skill resource or use the author_flint_chart prompt.' +
+ dataAccessNote(options),
},
);
@@ -153,7 +180,7 @@ export function createServer(options: CreateServerOptions = {}): McpServer {
format: args.format,
scale: args.scale,
background: args.background,
- dataRoots: dataSourceOptions.dataRoots,
+ disableFileReference: dataSourceOptions.disableFileReference,
});
const note =
`${res.backend} · ${res.format} · ${res.width}×${res.height}px` +
diff --git a/packages/flint-mcp/tests/data-roots.test.ts b/packages/flint-mcp/tests/data-roots.test.ts
deleted file mode 100644
index 0c3f40f..0000000
--- a/packages/flint-mcp/tests/data-roots.test.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-import { pathToFileURL } from 'node:url';
-import type { ChartAssemblyInput } from 'flint-chart';
-import { resolveDataSource, resolveDataRoots } from '../src/render/index.js';
-
-const CHART_SPEC = {
- chartType: 'Bar Chart',
- encodings: { x: { field: 'region' }, y: { field: 'revenue' } },
-};
-
-function inputWithUrl(url: string): ChartAssemblyInput {
- return {
- data: { url },
- semantic_types: { region: 'Category', revenue: 'Quantity' },
- chart_spec: CHART_SPEC,
- } as unknown as ChartAssemblyInput;
-}
-
-const CSV = 'region,revenue\nNorth,120\nSouth,90\nEast,150\n';
-
-let root: string;
-
-beforeEach(() => {
- // realpathSync so macOS /var → /private/var symlink doesn't trip the
- // inside-root containment check.
- root = realpathSync(mkdtempSync(join(tmpdir(), 'flint-data-roots-')));
-});
-
-afterEach(() => {
- rmSync(root, { recursive: true, force: true });
-});
-
-describe('data roots: local data.url loading', () => {
- it('loads a CSV referenced by a plain relative name under the root', () => {
- writeFileSync(join(root, 'sales.csv'), CSV);
- const out = resolveDataSource(inputWithUrl('sales.csv'), { dataRoots: [root] });
- expect((out.data as any).values).toHaveLength(3);
- expect((out.data as any).values[0]).toEqual({ region: 'North', revenue: 120 });
- expect((out.data as any).url).toBeUndefined();
- });
-
- it('loads a CSV from a nested subdirectory under the root', () => {
- mkdirSync(join(root, 'reports', '2026'), { recursive: true });
- writeFileSync(join(root, 'reports', '2026', 'sales.csv'), CSV);
- const out = resolveDataSource(inputWithUrl('reports/2026/sales.csv'), {
- dataRoots: [root],
- });
- expect((out.data as any).values).toHaveLength(3);
- });
-
- it('accepts a "./"-prefixed relative reference', () => {
- writeFileSync(join(root, 'sales.csv'), CSV);
- const out = resolveDataSource(inputWithUrl('./sales.csv'), { dataRoots: [root] });
- expect((out.data as any).values).toHaveLength(3);
- });
-
- it('accepts an absolute path that lives inside a configured root', () => {
- const abs = join(root, 'sales.csv');
- writeFileSync(abs, CSV);
- const out = resolveDataSource(inputWithUrl(abs), { dataRoots: [root] });
- expect((out.data as any).values).toHaveLength(3);
- });
-
- it('accepts a file:// URL inside a configured root', () => {
- const abs = join(root, 'sales.csv');
- writeFileSync(abs, CSV);
- const url = pathToFileURL(abs).href;
- const out = resolveDataSource(inputWithUrl(url), { dataRoots: [root] });
- expect((out.data as any).values).toHaveLength(3);
- });
-
- it('loads a JSON array file', () => {
- writeFileSync(
- join(root, 'sales.json'),
- JSON.stringify([{ region: 'North', revenue: 120 }]),
- );
- const out = resolveDataSource(inputWithUrl('sales.json'), { dataRoots: [root] });
- expect((out.data as any).values).toEqual([{ region: 'North', revenue: 120 }]);
- });
-
- it('loads a JSON { values: [...] } wrapper file', () => {
- writeFileSync(
- join(root, 'sales.json'),
- JSON.stringify({ values: [{ region: 'North', revenue: 120 }] }),
- );
- const out = resolveDataSource(inputWithUrl('sales.json'), { dataRoots: [root] });
- expect((out.data as any).values).toEqual([{ region: 'North', revenue: 120 }]);
- });
-
- it('loads a TSV file', () => {
- writeFileSync(join(root, 'sales.tsv'), 'region\trevenue\nNorth\t120\n');
- const out = resolveDataSource(inputWithUrl('sales.tsv'), { dataRoots: [root] });
- expect((out.data as any).values).toEqual([{ region: 'North', revenue: 120 }]);
- });
-});
-
-describe('data roots: multiple roots', () => {
- it('resolves a reference found only in the second configured root', () => {
- const second = realpathSync(mkdtempSync(join(tmpdir(), 'flint-data-roots-2-')));
- try {
- writeFileSync(join(second, 'sales.csv'), CSV);
- const out = resolveDataSource(inputWithUrl('sales.csv'), {
- dataRoots: [root, second],
- });
- expect((out.data as any).values).toHaveLength(3);
- } finally {
- rmSync(second, { recursive: true, force: true });
- }
- });
-
- it('resolveDataRoots dedupes and resolves to real absolute directories', () => {
- const roots = resolveDataRoots([root, root, `${root}/`]);
- expect(roots).toEqual([root]);
- });
-});
-
-describe('data roots: security + error handling', () => {
- it('rejects a relative reference that escapes the root via ".."', () => {
- writeFileSync(join(root, 'sales.csv'), CSV);
- const secret = join(root, '..', 'secret.csv');
- writeFileSync(secret, CSV);
- try {
- expect(() =>
- resolveDataSource(inputWithUrl('../secret.csv'), { dataRoots: [root] }),
- ).toThrow();
- } finally {
- rmSync(secret, { force: true });
- }
- });
-
- it('rejects an absolute path outside every configured root', () => {
- const outside = realpathSync(mkdtempSync(join(tmpdir(), 'flint-outside-')));
- try {
- const abs = join(outside, 'secret.csv');
- writeFileSync(abs, CSV);
- expect(() =>
- resolveDataSource(inputWithUrl(abs), { dataRoots: [root] }),
- ).toThrow(/outside the configured data roots|not found under configured data roots/i);
- } finally {
- rmSync(outside, { recursive: true, force: true });
- }
- });
-
- it('rejects a local reference when no data roots are configured', () => {
- expect(() => resolveDataSource(inputWithUrl('sales.csv'), {})).toThrow(
- /require --data-roots/i,
- );
- });
-
- it('rejects a missing file under a configured root', () => {
- expect(() =>
- resolveDataSource(inputWithUrl('nope.csv'), { dataRoots: [root] }),
- ).toThrow(/not found under configured data roots/i);
- });
-
- it('rejects an unsupported file extension', () => {
- writeFileSync(join(root, 'notes.txt'), 'hello');
- expect(() =>
- resolveDataSource(inputWithUrl('notes.txt'), { dataRoots: [root] }),
- ).toThrow(/\.json, \.csv, or \.tsv/i);
- });
-
- it('rejects a remote http(s) data.url even with roots configured', () => {
- expect(() =>
- resolveDataSource(inputWithUrl('https://example.com/sales.csv'), {
- dataRoots: [root],
- }),
- ).toThrow(/remote data\.url fetching is disabled/i);
- });
-});
diff --git a/packages/flint-mcp/tests/file-reference.test.ts b/packages/flint-mcp/tests/file-reference.test.ts
new file mode 100644
index 0000000..35d358a
--- /dev/null
+++ b/packages/flint-mcp/tests/file-reference.test.ts
@@ -0,0 +1,188 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import type { ChartAssemblyInput } from 'flint-chart';
+import { resolveDataSource } from '../src/render/index.js';
+
+const CHART_SPEC = {
+ chartType: 'Bar Chart',
+ encodings: { x: { field: 'region' }, y: { field: 'revenue' } },
+};
+
+function inputWithUrl(url: string): ChartAssemblyInput {
+ return {
+ data: { url },
+ semantic_types: { region: 'Category', revenue: 'Quantity' },
+ chart_spec: CHART_SPEC,
+ } as unknown as ChartAssemblyInput;
+}
+
+const CSV = 'region,revenue\nNorth,120\nSouth,90\nEast,150\n';
+
+let root: string;
+
+beforeEach(() => {
+ // realpathSync so macOS /var → /private/var symlink doesn't surprise the
+ // path resolution.
+ root = realpathSync(mkdtempSync(join(tmpdir(), 'flint-file-ref-')));
+});
+
+afterEach(() => {
+ rmSync(root, { recursive: true, force: true });
+});
+
+describe('trust mode (default): local data.url loading', () => {
+ it('reads a relative reference against the working directory', () => {
+ writeFileSync(join(root, 'sales.csv'), CSV);
+ const previousCwd = process.cwd();
+ try {
+ process.chdir(root);
+ const out = resolveDataSource(inputWithUrl('sales.csv'), {});
+ expect((out.data as any).values).toHaveLength(3);
+ expect((out.data as any).values[0]).toEqual({ region: 'North', revenue: 120 });
+ expect((out.data as any).url).toBeUndefined();
+ } finally {
+ process.chdir(previousCwd);
+ }
+ });
+
+ it('reads a "./"-prefixed relative reference', () => {
+ writeFileSync(join(root, 'sales.csv'), CSV);
+ const previousCwd = process.cwd();
+ try {
+ process.chdir(root);
+ const out = resolveDataSource(inputWithUrl('./sales.csv'), {});
+ expect((out.data as any).values).toHaveLength(3);
+ } finally {
+ process.chdir(previousCwd);
+ }
+ });
+
+ it('reads a nested relative reference', () => {
+ mkdirSync(join(root, 'reports', '2026'), { recursive: true });
+ writeFileSync(join(root, 'reports', '2026', 'sales.csv'), CSV);
+ const previousCwd = process.cwd();
+ try {
+ process.chdir(root);
+ const out = resolveDataSource(inputWithUrl('reports/2026/sales.csv'), {});
+ expect((out.data as any).values).toHaveLength(3);
+ } finally {
+ process.chdir(previousCwd);
+ }
+ });
+
+ it('reads an absolute path', () => {
+ const abs = join(root, 'sales.csv');
+ writeFileSync(abs, CSV);
+ const out = resolveDataSource(inputWithUrl(abs), {});
+ expect((out.data as any).values).toHaveLength(3);
+ });
+
+ it('reads a file:// URL', () => {
+ const abs = join(root, 'sales.csv');
+ writeFileSync(abs, CSV);
+ const url = pathToFileURL(abs).href;
+ const out = resolveDataSource(inputWithUrl(url), {});
+ expect((out.data as any).values).toHaveLength(3);
+ });
+
+ it('loads a JSON array file', () => {
+ const abs = join(root, 'sales.json');
+ writeFileSync(abs, JSON.stringify([{ region: 'North', revenue: 120 }]));
+ const out = resolveDataSource(inputWithUrl(abs), {});
+ expect((out.data as any).values).toEqual([{ region: 'North', revenue: 120 }]);
+ });
+
+ it('loads a JSON { values: [...] } wrapper file', () => {
+ const abs = join(root, 'sales.json');
+ writeFileSync(abs, JSON.stringify({ values: [{ region: 'North', revenue: 120 }] }));
+ const out = resolveDataSource(inputWithUrl(abs), {});
+ expect((out.data as any).values).toEqual([{ region: 'North', revenue: 120 }]);
+ });
+
+ it('loads a TSV file', () => {
+ const abs = join(root, 'sales.tsv');
+ writeFileSync(abs, 'region\trevenue\nNorth\t120\n');
+ const out = resolveDataSource(inputWithUrl(abs), {});
+ expect((out.data as any).values).toEqual([{ region: 'North', revenue: 120 }]);
+ });
+});
+
+describe('trust mode (default): error handling', () => {
+ it('reports a not-found error for a missing local reference', () => {
+ expect(() =>
+ resolveDataSource(inputWithUrl(join(root, 'definitely-missing.csv')), {}),
+ ).toThrow(/not found/i);
+ });
+
+ it('rejects an unsupported file extension', () => {
+ const abs = join(root, 'notes.txt');
+ writeFileSync(abs, 'hello');
+ expect(() => resolveDataSource(inputWithUrl(abs), {})).toThrow(
+ /\.json, \.csv, or \.tsv/i,
+ );
+ });
+
+ it('blocks remote http(s) URLs', () => {
+ expect(() =>
+ resolveDataSource(inputWithUrl('https://example.com/sales.csv'), {}),
+ ).toThrow(/remote data\.url fetching is disabled/i);
+ });
+});
+
+describe('disableFileReference: local file references rejected', () => {
+ it('rejects a local relative data.url reference', () => {
+ writeFileSync(join(root, 'sales.csv'), CSV);
+ const previousCwd = process.cwd();
+ try {
+ process.chdir(root);
+ expect(() =>
+ resolveDataSource(inputWithUrl('sales.csv'), { disableFileReference: true }),
+ ).toThrow(/disabled on this server/i);
+ } finally {
+ process.chdir(previousCwd);
+ }
+ });
+
+ it('rejects a local absolute data.url reference', () => {
+ const abs = join(root, 'sales.csv');
+ writeFileSync(abs, CSV);
+ expect(() =>
+ resolveDataSource(inputWithUrl(abs), { disableFileReference: true }),
+ ).toThrow(/disabled on this server/i);
+ });
+
+ it('rejects a local file:// URL reference', () => {
+ const abs = join(root, 'sales.csv');
+ writeFileSync(abs, CSV);
+ const url = pathToFileURL(abs).href;
+ expect(() =>
+ resolveDataSource(inputWithUrl(url), { disableFileReference: true }),
+ ).toThrow(/disabled on this server/i);
+ });
+
+ it('still accepts inline data.values', () => {
+ const out = resolveDataSource(
+ {
+ data: { values: [{ region: 'North', revenue: 120 }] },
+ semantic_types: { region: 'Category', revenue: 'Quantity' },
+ chart_spec: CHART_SPEC,
+ } as unknown as ChartAssemblyInput,
+ { disableFileReference: true },
+ );
+ expect((out.data as any).values).toEqual([{ region: 'North', revenue: 120 }]);
+ });
+
+ it('still blocks remote http(s) URLs', () => {
+ expect(() =>
+ resolveDataSource(inputWithUrl('https://example.com/sales.csv'), {
+ disableFileReference: true,
+ }),
+ ).toThrow(/remote data\.url fetching is disabled/i);
+ });
+});
diff --git a/packages/flint-mcp/tests/render.test.ts b/packages/flint-mcp/tests/render.test.ts
index c5a2567..4f1df9d 100644
--- a/packages/flint-mcp/tests/render.test.ts
+++ b/packages/flint-mcp/tests/render.test.ts
@@ -70,30 +70,49 @@ describe('input guards', () => {
await expect(renderChart(bad, 'vegalite')).rejects.toThrow(/url fetching is disabled/i);
});
- it('rejects local data.url without configured data roots', async () => {
+ it('rejects a local data.url that cannot be found', async () => {
const bad = {
...sales,
- data: { url: 'sales.csv' },
+ data: { url: 'definitely-missing.csv' },
} as unknown as ChartAssemblyInput;
- await expect(renderChart(bad, 'vegalite')).rejects.toThrow(/data.url references require/i);
+ await expect(renderChart(bad, 'vegalite')).rejects.toThrow(/not found/i);
});
- it('loads local CSV data.url from configured data roots', async () => {
- const dataRoot = mkdtempSync(join(tmpdir(), 'flint-mcp-data-'));
+ it('loads local CSV data.url in trust mode (default)', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'flint-mcp-data-'));
+ const previousCwd = process.cwd();
try {
- writeFileSync(join(dataRoot, 'sales.csv'), 'region,revenue\nNorth,120\nSouth,90\n');
+ writeFileSync(join(dataDir, 'sales.csv'), 'region,revenue\nNorth,120\nSouth,90\n');
+ process.chdir(dataDir);
const input = {
...sales,
data: { url: 'sales.csv' },
} as unknown as ChartAssemblyInput;
const res = await renderChart(input, 'vegalite', {
format: 'svg',
- dataRoots: [dataRoot],
});
expect(res.mimeType).toBe('image/svg+xml');
expect(res.svg).toContain('