From 8395331445c05e6ab8c4fb1888b38afd2bbbec81 Mon Sep 17 00:00:00 2001 From: Shayne Boyer <7681382+spboyer@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:18:13 -0400 Subject: [PATCH 1/9] Add Flint chart-first MCP App Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b6d0c5f-588a-4dc5-86de-99a1c8eb323e --- packages/flint-mcp/README.md | 15 +- .../assets/fonts/MonaSans-OFL-1.1.txt | 93 +++ packages/flint-mcp/tests/mcp-app-ui.test.ts | 51 ++ packages/flint-mcp/ui/src/FlintApp.tsx | 614 +++++++++--------- .../flint-mcp/ui/src/assets/MonaSansVF.woff2 | Bin 0 -> 137252 bytes packages/flint-mcp/ui/src/assets/README.md | 9 + packages/flint-mcp/ui/src/options.ts | 44 ++ packages/flint-mcp/ui/src/render.ts | 63 +- packages/flint-mcp/ui/src/styles.css | 527 ++++----------- packages/flint-mcp/vitest.config.ts | 2 +- 10 files changed, 721 insertions(+), 697 deletions(-) create mode 100644 packages/flint-mcp/assets/fonts/MonaSans-OFL-1.1.txt create mode 100644 packages/flint-mcp/tests/mcp-app-ui.test.ts create mode 100644 packages/flint-mcp/ui/src/assets/MonaSansVF.woff2 create mode 100644 packages/flint-mcp/ui/src/assets/README.md diff --git a/packages/flint-mcp/README.md b/packages/flint-mcp/README.md index 9dcae739..8815860a 100644 --- a/packages/flint-mcp/README.md +++ b/packages/flint-mcp/README.md @@ -30,12 +30,15 @@ renders **locally**. ## MCP App: interactive chart view In hosts that support MCP App UIs (e.g. Claude Desktop), `create_chart_view` -opens an interactive view that renders the spec live (Vega-Lite → SVG) and shows -a customization panel built from Flint's own option model — chart type, channel -bindings, chart properties (corner radius, stack mode, donut hole, …), and -encoding actions (sort). Rendering and edits run entirely in the host UI; no -data leaves the host. The UI is a single self-contained HTML bundle served as -the `ui://flint-chart/chart-view.html` resource and built with `npm run build:ui`. +opens a chart-first workspace with a live Flint preview and a Vega-Lite output +tab. Its compact toolbar changes chart views, chart types, and dimensions through +Flint's own metadata; **Edit chart** reveals channel bindings and applicable chart +properties (corner radius, stack mode, donut hole, …) only when needed. Rendering, +validation, compilation, and edits run entirely in the host UI; no data leaves +the host. The UI is a self-contained HTML bundle served as +`ui://flint-chart/chart-view.html` and built with `npm run build:ui`. It uses +bundled Mona Sans under the SIL Open Font License 1.1; see +`assets/fonts/MonaSans-OFL-1.1.txt` for the complete license text. ## Resources and prompt diff --git a/packages/flint-mcp/assets/fonts/MonaSans-OFL-1.1.txt b/packages/flint-mcp/assets/fonts/MonaSans-OFL-1.1.txt new file mode 100644 index 00000000..c2bdb909 --- /dev/null +++ b/packages/flint-mcp/assets/fonts/MonaSans-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright (c) 2023, GitHub https://github.com/github/mona-sans +with Reserved Font Name "Mona Sans" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/flint-mcp/tests/mcp-app-ui.test.ts b/packages/flint-mcp/tests/mcp-app-ui.test.ts new file mode 100644 index 00000000..704b37ff --- /dev/null +++ b/packages/flint-mcp/tests/mcp-app-ui.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import type { ChartAssemblyInput } from 'flint-chart'; + +import { setBaseSize, setCompatibleChartType } from '../ui/src/options.js'; +import { compileFlintVegaLite, validateFlintChart } from '../ui/src/render.js'; + +const barChart: ChartAssemblyInput = { + data: { + values: [ + { region: 'North', revenue: 120 }, + { region: 'South', revenue: 90 }, + ], + }, + semantic_types: { region: 'Nominal', revenue: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + }, +}; + +describe('Flint MCP App helpers', () => { + it('adapts encodings through Flint template metadata when switching to pie', () => { + const pie = setCompatibleChartType(barChart, 'Pie Chart'); + + expect(pie.chart_spec.chartType).toBe('Pie Chart'); + expect(pie.chart_spec.encodings).toEqual({ + color: { field: 'region' }, + size: { field: 'revenue' }, + }); + }); + + it('updates authored dimensions without changing the input', () => { + const resized = setBaseSize(barChart, 'width', 480); + + expect(resized.chart_spec.baseSize).toEqual({ width: 480, height: 240 }); + expect(barChart.chart_spec.baseSize).toBeUndefined(); + }); + + it('validates and compiles a public Vega-Lite output with Flint', () => { + const validation = validateFlintChart(barChart); + const compiled = compileFlintVegaLite(barChart); + + expect(validation.valid).toBe(true); + expect(compiled.spec).not.toHaveProperty('_warnings'); + expect(compiled.spec).toHaveProperty('mark'); + expect(compiled.computedSize).toEqual(expect.objectContaining({ width: expect.any(Number) })); + }); +}); diff --git a/packages/flint-mcp/ui/src/FlintApp.tsx b/packages/flint-mcp/ui/src/FlintApp.tsx index 043a4e44..04abfc42 100644 --- a/packages/flint-mcp/ui/src/FlintApp.tsx +++ b/packages/flint-mcp/ui/src/FlintApp.tsx @@ -1,214 +1,256 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -/** - * Flint chart MCP App. - * - * Renders a Flint chart spec live (client-side Flint -> Vega-Lite -> SVG) and - * offers a data-driven customization panel built entirely from Flint's own - * option model (chart type, channel bindings, chart properties, encoding - * actions). Mirrors Data Formulator's encoding-shelf idea, restricted to Flint - * options, with no server round-trips. - */ import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps'; import { useApp } from '@modelcontextprotocol/ext-apps/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ChartAssemblyInput, ChartOption } from 'flint-chart'; +import type { ChartAssemblyInput, ChartOption, ChartWarning } from 'flint-chart'; -import { renderFlintSvg, type FlintRenderResult } from './render'; import { buildPanelModel, + dataColumns, + setBaseSize, + setCompatibleChartType, setProperty, type PanelModel, type ResolvedAction, } from './options'; +import { + renderFlintSvg, + validateFlintChart, + type FlintRenderResult, + type FlintValidationResult, +} from './render'; declare const __FLINT_MCP_VERSION__: string; -/** Control descriptor shared by chart properties and encoding actions. */ +type ViewMode = 'preview' | 'vega-lite'; + type ControlSpec = | { type: 'continuous'; min: number; max: number; step?: number } | { type: 'discrete'; options: { value: unknown; label: string }[] } | { type: 'binary' }; -/** Stable string key for an arbitrary option value (handles undefined/objects). */ function valueKey(value: unknown): string { return JSON.stringify(value ?? null); } -function compactSelectLabel(label: string): string { - const withoutHint = label.replace(/\s*\([^)]*\)\s*$/u, '').trim(); - if (withoutHint.length <= 16) return withoutHint; - return `${withoutHint.slice(0, 13).trimEnd()}...`; +function CopyPngButton({ svg }: { svg?: string }) { + const [state, setState] = useState<'idle' | 'copied' | 'error'>('idle'); + + const copy = useCallback(async () => { + if (!svg) return; + try { + const blob = await svgToPng(svg); + if (!navigator.clipboard?.write || !('ClipboardItem' in window)) { + throw new Error('This MCP App host does not support image clipboard access.'); + } + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }), + ]); + setState('copied'); + } catch { + setState('error'); + } + }, [svg]); + + return ( + + ); +} + +function svgToPng(svg: string): Promise { + return new Promise((resolve, reject) => { + const source = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })); + const image = new Image(); + image.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || 720; + canvas.height = image.naturalHeight || 480; + const context = canvas.getContext('2d'); + if (!context) { + URL.revokeObjectURL(source); + reject(new Error('Canvas rendering is unavailable.')); + return; + } + context.fillStyle = '#0d1117'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + canvas.toBlob((blob) => { + URL.revokeObjectURL(source); + if (blob) resolve(blob); + else reject(new Error('Could not create a PNG.')); + }, 'image/png'); + }; + image.onerror = () => { + URL.revokeObjectURL(source); + reject(new Error('Could not read the chart SVG.')); + }; + image.src = source; + }); +} + +function ChartSelector(props: { + input: ChartAssemblyInput; + model: PanelModel; + onInput: (input: ChartAssemblyInput) => void; +}) { + const { input, model, onInput } = props; + const pivot = model.pivot; + const value = pivot ? String(pivot.index) : 'current'; + + return ( + + ); +} + +function ChartTypeSelector(props: { + input: ChartAssemblyInput; + model: PanelModel; + onInput: (input: ChartAssemblyInput) => void; +}) { + const { input, model, onInput } = props; + return ( + + ); } -// Best-effort sizing: measure each option by its label length + the intrinsic -// width of its widget, then snap to a small set of tiers. Keeps the strip -// grid-like (few distinct widths) while letting toggles stay compact and -// sliders/selects get the room they need. -const LABEL_CHAR_PX = 6; -const LABEL_MAX_PX = 96; -const LABEL_GAP = 6; -// Small safety margin so short labels (e.g. "Gap") aren't starved by the -// fixed-width widget and snap up to the next tier when the fit is tight. -const FIT_BUFFER = 10; -const WIDGET_PX: Record = { - continuous: 56 + 4 + 32, // slider track + gap + readout - discrete: 104, // select - binary: 24, // toggle - pivot: 78, // stepper -}; -const WIDTH_TIERS = [96, 116, 136, 156, 180, 204]; +function DimensionsControl(props: { + input: ChartAssemblyInput; + onInput: (input: ChartAssemblyInput) => void; +}) { + const { input, onInput } = props; + const size = input.chart_spec.baseSize ?? { width: 360, height: 240 }; + + function change(dimension: 'width' | 'height', rawValue: string) { + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 1 || value > 4000) return; + onInput(setBaseSize(input, dimension, Math.round(value))); + } -function optionWidth(label: string, kind: string): number { - const labelPx = Math.min(LABEL_MAX_PX, Math.ceil(label.length * LABEL_CHAR_PX)); - const needed = labelPx + LABEL_GAP + (WIDGET_PX[kind] ?? 120) + FIT_BUFFER; - return WIDTH_TIERS.find((t) => t >= needed) ?? WIDTH_TIERS[WIDTH_TIERS.length - 1]; + return ( + + Dimensions + change('width', event.target.value)} + type="number" + value={size.width} + /> + + change('height', event.target.value)} + type="number" + value={size.height} + /> + + ); } function ControlRow(props: { label: string; spec: ControlSpec; value: unknown; - width: number; onChange: (value: unknown) => void; }) { - const { label, spec, value, width, onChange } = props; - - let control: React.ReactNode = null; + const { label, spec, value, onChange } = props; if (spec.type === 'continuous') { - const step = spec.step ?? ((spec.max - spec.min) / 100 || 1); - const num = typeof value === 'number' ? value : spec.min; - const pct = spec.max > spec.min ? ((num - spec.min) / (spec.max - spec.min)) * 100 : 0; - // Reserve exactly enough room for the widest value this slider can actually - // show, so the readout sits immediately after the track yet never reflows - // the chip while dragging. Scan the real on-grid values (snapped to step) so - // we don't over-reserve for off-grid fractions like 0.55 on a 0.1 step. - const fmt = (n: number) => Number(n).toLocaleString(); - const decimals = (String(step).split('.')[1] ?? '').length; - const snap = (n: number) => Number(n.toFixed(decimals)); - const stepN = step > 0 ? step : (spec.max - spec.min) || 1; - const count = Math.min(200, Math.max(1, Math.floor((spec.max - spec.min) / stepN))); - let readoutCh = 1; - for (let i = 0; i <= count; i++) { - const v = snap(spec.min + i * stepN); - if (v > spec.max + 1e-9) break; - readoutCh = Math.max(readoutCh, fmt(v).length); - } - control = ( - + const numberValue = typeof value === 'number' ? value : spec.min; + return ( + + {numberValue} + ); - } else if (spec.type === 'discrete') { - const current = valueKey(value); - const idx = spec.options.findIndex((o) => valueKey(o.value) === current); - const selectedIndex = idx < 0 ? 0 : idx; - const selected = spec.options[selectedIndex]; - // Hug the *current* value, not the widest option. A sized text label shows - // the selection while a transparent native onChange(spec.options[Number(e.target.value)]?.value)} + onChange={(event) => onChange(spec.options[Number(event.target.value)]?.value)} + value={String(Math.max(0, selected))} > - {spec.options.map((o, i) => ( - ))} - - ); - } else { - control = ( - - onChange(e.target.checked)} - /> - + ); } - return ( -