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 [![npm: flint-chart](https://img.shields.io/npm/v/flint-chart.svg?label=npm%3A%20flint-chart)](https://www.npmjs.com/package/flint-chart) [![npm: flint-chart-mcp](https://img.shields.io/npm/v/flint-chart-mcp.svg?label=npm%3A%20flint-chart-mcp)](https://www.npmjs.com/package/flint-chart-mcp) @@ -111,8 +111,8 @@ includes client configuration, usage examples, and links to deeper references. Agent chat showing Flint Chart as an MCP App with a grouped bar chart preview and chart options.

-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(' { + const dataDir = mkdtempSync(join(tmpdir(), 'flint-mcp-data-')); + try { + const abs = join(dataDir, 'sales.csv'); + writeFileSync(abs, 'region,revenue\nNorth,120\nSouth,90\n'); + const input = { + ...sales, + data: { url: abs }, + } as unknown as ChartAssemblyInput; + await expect( + renderChart(input, 'vegalite', { format: 'svg', disableFileReference: true }), + ).rejects.toThrow(/disabled on this server/i); + } finally { + rmSync(dataDir, { recursive: true, force: true }); } }); }); diff --git a/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts index d5b09be..3caa7e6 100644 --- a/packages/flint-mcp/tests/server.test.ts +++ b/packages/flint-mcp/tests/server.test.ts @@ -230,18 +230,19 @@ describe('MCP server', () => { expect(bundledSkill).toBe(repoSkill); }); - it('passes configured data roots to chart tools', async () => { - const dataRoot = mkdtempSync(join(tmpdir(), 'flint-mcp-server-data-')); - const dataServer = createServer({ dataRoots: [dataRoot] }); + it('reads a local data.url file and inlines its rows', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'flint-mcp-server-data-')); + const dataServer = createServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const dataClient = new Client({ name: 'flint-data-root-test', version: '0.0.0' }); + const dataClient = new Client({ name: 'flint-data-file-test', version: '0.0.0' }); try { - writeFileSync(join(dataRoot, 'sales.csv'), 'region,revenue\nNorth,120\nSouth,90\n'); + const csvPath = join(dataDir, 'sales.csv'); + writeFileSync(csvPath, 'region,revenue\nNorth,120\nSouth,90\n'); await dataServer.connect(serverTransport); await dataClient.connect(clientTransport); const res: any = await dataClient.callTool({ name: 'compile_chart', - arguments: { ...barChart, data: { url: 'sales.csv' }, backend: 'vegalite' }, + arguments: { ...barChart, data: { url: csvPath }, backend: 'vegalite' }, }); const payload = JSON.parse(res.content[0].text); expect(payload.backend).toBe('vegalite'); @@ -249,22 +250,23 @@ describe('MCP server', () => { } finally { await dataClient.close(); await dataServer.close(); - rmSync(dataRoot, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); } }); it('inlines local data.url rows into create_chart_view structuredContent', async () => { - const dataRoot = mkdtempSync(join(tmpdir(), 'flint-mcp-view-data-')); - const dataServer = createServer({ dataRoots: [dataRoot] }); + const dataDir = mkdtempSync(join(tmpdir(), 'flint-mcp-view-data-')); + const dataServer = createServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const dataClient = new Client({ name: 'flint-view-data-test', version: '0.0.0' }); try { - writeFileSync(join(dataRoot, 'sales.csv'), 'region,revenue\nNorth,120\nSouth,90\n'); + const csvPath = join(dataDir, 'sales.csv'); + writeFileSync(csvPath, 'region,revenue\nNorth,120\nSouth,90\n'); await dataServer.connect(serverTransport); await dataClient.connect(clientTransport); const res: any = await dataClient.callTool({ name: 'create_chart_view', - arguments: { ...barChart, data: { url: 'sales.csv' } }, + arguments: { ...barChart, data: { url: csvPath } }, }); expect(res.isError).toBeFalsy(); // The host UI renders client-side and cannot read local files, so the @@ -278,7 +280,7 @@ describe('MCP server', () => { } finally { await dataClient.close(); await dataServer.close(); - rmSync(dataRoot, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); } }); }); diff --git a/site/index.html b/site/index.html index 16a0ccc..a5b85a1 100644 --- a/site/index.html +++ b/site/index.html @@ -3,7 +3,7 @@ - Flint: A Semantic Visualization Language for AI Agents and Humans + Flint: A Visualization Language for the AI Era @@ -14,26 +19,34 @@ export function McpServer() {
{/* ---- Hero -------------------------------------------------- */}
-

Use Flint as a MCP server for your agent

+

Use Flint as an MCP server for your agent

Install flint-chart-mcp as a{' '} Model Context Protocol {' '} - server and your agent can create charts from the same conversation - where the question starts. By default it opens an interactive Flint + server and your agent can create charts as an interactive MCP app. By default it opens an interactive Flint chart view; when you need artifacts, it can also return static images or backend-native specs.

- + . +

+ + {setupPrompt}
{/* ---- Article body ----------------------------------------- */} @@ -43,14 +56,18 @@ export function McpServer() {

The experience

Using Flint through MCP is a simple loop: connect the server, ask - for the chart you want, and work with the rendered result in the - same chat. + for the chart you want, and work with a visualization with dynamic widgets provided by the MCP server.

+ + + + +
  1. Connect Flint MCP server. Add the stdio server to your - MCP client. If the agent should chart local CSV, TSV, or JSON - files, grant a data root explicitly. + MCP client. The agent can chart local CSV, TSV, or JSON + files by default.
  2. Ask for a chart. The agent turns your request @@ -66,17 +83,6 @@ export function McpServer() {
- - - -

- The embedded chart is genuinely Flint-rendered, using the same path - as the MCP App. The frame and toolbar mirror the real{' '} - create_chart_view UI: a live - preview with chart options and a Copy spec to chat action. -

-
- {/* ---- What it provides ------------------------------------- */}

What it provides

@@ -133,32 +139,29 @@ export function McpServer() { {/* ---- Install ---------------------------------------------- */} -

Install & configure

+

Install & configure

- The server speaks stdio and runs zero-install with{' '} - npx. If your MCP client can - edit its own configuration, you can ask your agent to set it up: + For manual setup, the server speaks stdio and runs zero-install with{' '} + npx. Point your MCP client at the package:

- {setupPrompt} - - -

Or point your MCP client at the package manually:

-
- {clientConfig}

Tool calls can embed rows directly with{' '} - data.values. If you want the - agent to chart a local CSV, TSV, or JSON file instead, grant an - explicit data root. Remote URLs are never fetched: + data.values. The agent can + also chart a local CSV, TSV, or JSON file by{' '} + data.url out of the box. + Remote URLs are never fetched. For an untrusted deployment, pass{' '} + --disable-file-reference to + reject local file references and accept only inline{' '} + data.values:

- {dataRootsConfig} + {disableFileReferenceConfig} {/* ---- Next ------------------------------------------------- */} @@ -304,20 +307,33 @@ function SurfaceCard(props: { tag: string; name: string; desc: string; highlight ); } -function CodeBlock({ children }: { children: string }) { +function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) { + const [copied, setCopied] = useState(false); + + async function copyCode() { + await navigator.clipboard.writeText(children); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + } + return (
-
{children}
+ {copyable ? ( + + ) : null} +
{children}
); } -const setupPrompt = `Set up Flint as an MCP server for this project. +const setupPrompt = `Help me set up Flint as an MCP server! -Use the package flint-chart-mcp through npx: - npx -y flint-chart-mcp - -Add it to the MCP client config as a stdio server named "flint". If this workspace has a ./data folder, allow it with --data-roots ./data. After setup, verify the server by listing the available Flint chart types.`; +1. Add a stdio MCP server named "flint" to my client config that runs: + npx -y flint-chart-mcp +2. Verify the setup by asking the server to list the available Flint chart types.`; const clientConfig = `{ "mcpServers": { @@ -328,11 +344,11 @@ const clientConfig = `{ } }`; -const dataRootsConfig = `{ +const disableFileReferenceConfig = `{ "mcpServers": { "flint": { "command": "npx", - "args": ["-y", "flint-chart-mcp", "--data-roots", "./data"] + "args": ["-y", "flint-chart-mcp", "--disable-file-reference"] } } }`; @@ -391,35 +407,37 @@ const leadStyle: CSSProperties = { fontWeight: 400, }; -const installRowStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 16, - marginTop: 32, - flexWrap: 'wrap', +const setupLeadStyle: CSSProperties = { + ...leadStyle, + marginTop: 24, + marginBottom: 16, }; -const installCodeStyle: CSSProperties = { - fontFamily: siteTheme.fontMono, - fontSize: 14, +const setupLabelStyle: CSSProperties = { color: siteTheme.text, - background: 'rgba(0,0,0,0.04)', - border: `1px solid ${HAIRLINE}`, - borderRadius: siteTheme.radius, - padding: '9px 14px', + fontWeight: 700, }; -const promptMarkStyle: CSSProperties = { - color: siteTheme.textMuted, - userSelect: 'none', - marginRight: 8, +const setupLabelIconStyle: CSSProperties = { + marginRight: 6, }; -const ghLinkStyle: CSSProperties = { +const setupInlineLinkStyle: CSSProperties = { color: siteTheme.accent, - fontSize: 14, - fontWeight: 500, textDecoration: 'none', + fontWeight: 500, +}; + +const setupInlineButtonStyle: CSSProperties = { + color: siteTheme.accent, + background: 'none', + border: 0, + padding: 0, + cursor: 'pointer', + fontFamily: 'inherit', + fontSize: 'inherit', + fontWeight: 500, + lineHeight: 'inherit', }; const articleStyle: CSSProperties = { @@ -461,7 +479,7 @@ const captionStyle: CSSProperties = { }; const stepListStyle: CSSProperties = { - margin: '14px 0 0', + margin: '32px 0 0', padding: '0 0 0 22px', display: 'flex', flexDirection: 'column', @@ -852,6 +870,7 @@ const codeBlockWrapStyle: CSSProperties = { borderRadius: 8, background: 'rgba(0,0,0,0.025)', overflow: 'auto', + position: 'relative', }; const codeBlockStyle: CSSProperties = { @@ -863,6 +882,33 @@ const codeBlockStyle: CSSProperties = { color: siteTheme.text, }; +const codeBlockCopyableStyle: CSSProperties = { + paddingRight: 128, +}; + +const copyButtonStyle: CSSProperties = { + position: 'absolute', + top: 8, + right: 8, + display: 'inline-flex', + alignItems: 'center', + gap: 6, + border: `1px solid rgba(0, 102, 204, 0.24)`, + borderRadius: 7, + background: 'rgba(0, 102, 204, 0.08)', + color: siteTheme.accent, + fontFamily: siteTheme.fontSans, + fontSize: 12.5, + fontWeight: 600, + padding: '5px 10px', + cursor: 'pointer', +}; + +const copyIconStyle: CSSProperties = { + fontSize: 13, + lineHeight: 1, +}; + /* ---- next actions ---- */ const nextRowStyle: CSSProperties = {