diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 2bd8ae86..94cf2438 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -26,7 +26,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: https://registry.npmjs.org/ - name: Use npm 11 for trusted publishing run: npm install -g npm@11 @@ -77,6 +76,21 @@ jobs: - name: Publish flint-chart-mcp run: npm publish --workspace packages/flint-mcp --access public + - name: Wait for flint-chart-mcp to resolve + shell: bash + run: | + set -euo pipefail + for attempt in {1..30}; do + if npm view "flint-chart-mcp@${mcp_version}" version >/dev/null 2>&1; then + npm view "flint-chart-mcp@${mcp_version}" version + exit 0 + fi + echo "Waiting for flint-chart-mcp@${mcp_version} to be visible on npm (${attempt}/30)..." + sleep 10 + done + echo "::error::flint-chart-mcp@${mcp_version} did not become visible on npm in time." + exit 1 + - name: Verify published packages run: | npm dist-tag ls flint-chart diff --git a/package-lock.json b/package-lock.json index d88d025e..b4a7233b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9911,7 +9911,7 @@ }, "packages/flint-js": { "name": "flint-chart", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "devDependencies": { "@types/node": "^20.14.10", @@ -9952,7 +9952,7 @@ }, "packages/flint-mcp": { "name": "flint-chart-mcp", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.4", @@ -9962,7 +9962,7 @@ "canvas": "^3.2.3", "chart.js": "^4.4.0", "echarts": "^6.0.0", - "flint-chart": "^0.1.1", + "flint-chart": "^0.1.2", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 224782cc..a6962e6f 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart", - "version": "0.1.1", + "version": "0.1.2", "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-mcp/package.json b/packages/flint-mcp/package.json index 60b50ce8..00eb777c 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart-mcp", - "version": "0.1.1", + "version": "0.1.2", "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", @@ -69,7 +69,7 @@ "canvas": "^3.2.3", "chart.js": "^4.4.0", "echarts": "^6.0.0", - "flint-chart": "^0.1.1", + "flint-chart": "^0.1.2", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/packages/flint-mcp/src/render/assemble.ts b/packages/flint-mcp/src/render/assemble.ts index d0904366..b33899c7 100644 --- a/packages/flint-mcp/src/render/assemble.ts +++ b/packages/flint-mcp/src/render/assemble.ts @@ -6,7 +6,12 @@ import { assembleECharts, assembleChartjs, type ChartAssemblyInput, + type ChartEncoding, + type ChartTemplateDef, type ChartWarning, + vlGetTemplateDef, + ecGetTemplateDef, + cjsGetTemplateDef, } from 'flint-chart'; import { resolveDataSource, @@ -29,6 +34,12 @@ const ASSEMBLERS: Record< chartjs: assembleChartjs, }; +const TEMPLATE_LOOKUP: Record ChartTemplateDef | undefined> = { + vegalite: vlGetTemplateDef, + echarts: ecGetTemplateDef, + chartjs: cjsGetTemplateDef, +}; + export interface AssembleResult { /** The backend-native spec (still carrying Flint's private `_`-keys). */ spec: any; @@ -56,6 +67,7 @@ export function validateInput( export function prepareInput( input: ChartAssemblyInput, options: DataSourceOptions = {}, + backend?: RenderBackend, ): ChartAssemblyInput { if (input == null || typeof input !== 'object') { throw new Error('input must be a ChartAssemblyInput object'); @@ -82,6 +94,10 @@ export function prepareInput( if (cs == null || typeof cs !== 'object' || typeof cs.chartType !== 'string') { throw new Error('input.chart_spec.chartType is required'); } + if (resolvedData.values.length === 0) { + throw new Error('input.data.values must contain at least one row'); + } + validateChartSpec(cs, resolvedData.values, backend); for (const field of ['baseSize', 'canvasSize'] as const) { const size = cs[field]; if (size) { @@ -98,6 +114,77 @@ export function prepareInput( return resolvedInput; } +function validateChartSpec(cs: any, rows: Record[], backend?: RenderBackend): void { + const encodings = cs.encodings; + if (encodings == null || typeof encodings !== 'object' || Array.isArray(encodings)) { + throw new Error('input.chart_spec.encodings must be a channel-to-encoding object'); + } + + const entries = Object.entries(encodings); + if (entries.length === 0) { + throw new Error('input.chart_spec.encodings must bind at least one channel'); + } + + const template = backend ? TEMPLATE_LOOKUP[backend]?.(cs.chartType) : undefined; + if (backend && !template) return; + + if (template) { + const allowed = new Set(template.channels ?? []); + for (const [channel] of entries) { + if (!allowed.has(channel)) { + throw new Error( + `chart_spec.encodings.${channel} is not supported by ${cs.chartType} for ${backend}`, + ); + } + } + + for (const channel of requiredChannels(template)) { + if (!hasEncodingBinding(encodings[channel])) { + throw new Error(`chart_spec.encodings.${channel} is required for ${cs.chartType}`); + } + } + } + + const dataFields = new Set(rows.flatMap((row) => Object.keys(row))); + for (const [channel, encoding] of entries) { + for (const field of encodingFields(encoding)) { + if (!dataFields.has(field)) { + throw new Error(`chart_spec.encodings.${channel}.field "${field}" does not exist in data.values`); + } + } + } +} + +function requiredChannels(template: ChartTemplateDef): string[] { + const channels = template.channels ?? []; + if (channels.includes('x') && channels.includes('y')) return ['x', 'y']; + if (template.chart === 'KPI Card') return ['metric', 'value']; + return []; +} + +function hasEncodingBinding(value: unknown): boolean { + if (typeof value === 'string') return value.trim().length > 0; + if (Array.isArray(value)) return value.some(hasEncodingBinding); + if (value && typeof value === 'object') { + const encoding = value as ChartEncoding; + return ( + (typeof encoding.field === 'string' && encoding.field.trim().length > 0) || + encoding.aggregate === 'count' + ); + } + return false; +} + +function encodingFields(value: unknown): string[] { + if (typeof value === 'string') return value.trim() ? [value] : []; + if (Array.isArray(value)) return value.flatMap(encodingFields); + if (value && typeof value === 'object') { + const field = (value as ChartEncoding).field; + return typeof field === 'string' && field.trim() ? [field] : []; + } + return []; +} + /** * Assemble a Flint spec for one backend and split out Flint's private metadata * (`_warnings`, `_width`, `_height`). The returned `spec` is left untouched so @@ -112,7 +199,7 @@ export function assembleForBackend( if (!assemble) { throw new Error(`unknown backend: ${backend}`); } - const resolvedInput = prepareInput(input, options); + const resolvedInput = prepareInput(input, options, backend); const spec = assemble(resolvedInput); const warnings: ChartWarning[] = Array.isArray(spec?._warnings) ? spec._warnings diff --git a/packages/flint-mcp/src/render/vegalite.ts b/packages/flint-mcp/src/render/vegalite.ts index 23133ce3..c5e1eabc 100644 --- a/packages/flint-mcp/src/render/vegalite.ts +++ b/packages/flint-mcp/src/render/vegalite.ts @@ -8,6 +8,13 @@ import type { RenderResult, RenderFormat } from './types.js'; const DEFAULT_W = 400; const DEFAULT_H = 320; +function readSvgDimension(svg: string, attr: 'width' | 'height'): number | undefined { + const match = svg.match(new RegExp(`]*\\s${attr}="([^\"]+)"`, 'i')); + if (!match) return undefined; + const value = Number.parseFloat(match[1]); + return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined; +} + export interface VegaLiteRenderArgs { format: RenderFormat; scale: number; @@ -49,9 +56,10 @@ export async function renderVegaLite( const svg = await view.toSVG(); view.finalize(); - // Vega reports the rendered content box; fall back to the assembler size. - const width = Math.round((view.width?.() as number) || args.width || DEFAULT_W); - const height = Math.round((view.height?.() as number) || args.height || DEFAULT_H); + // Vega's View width/height report the content box, excluding axes/legends. + // The root SVG dimensions are the actual artifact dimensions used by resvg. + const width = readSvgDimension(svg, 'width') ?? args.width ?? DEFAULT_W; + const height = readSvgDimension(svg, 'height') ?? args.height ?? DEFAULT_H; return svgToResult(svg, { backend: 'vegalite', diff --git a/packages/flint-mcp/src/server.ts b/packages/flint-mcp/src/server.ts index a4199961..63a8fb80 100644 --- a/packages/flint-mcp/src/server.ts +++ b/packages/flint-mcp/src/server.ts @@ -24,7 +24,9 @@ import { } from './tools/schemas.js'; /** Package version, kept in lockstep with the npm release. */ -export const VERSION = '0.1.0'; +export const VERSION = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version as string; export const AGENT_SKILL_RESOURCE_URI = 'flint://agent-skill'; const AGENT_SKILL_ASSET = new URL('../assets/flint-chart-author.SKILL.md', import.meta.url); diff --git a/packages/flint-mcp/tests/render.test.ts b/packages/flint-mcp/tests/render.test.ts index abaff147..c5a2567d 100644 --- a/packages/flint-mcp/tests/render.test.ts +++ b/packages/flint-mcp/tests/render.test.ts @@ -24,6 +24,10 @@ const sales: ChartAssemblyInput = { const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]); +function pngDimensions(buffer: Buffer): { width: number; height: number } { + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + describe('renderChart → PNG', () => { for (const backend of ['vegalite', 'echarts', 'chartjs'] as const) { it(`renders a ${backend} bar chart to a non-trivial PNG`, async () => { @@ -33,6 +37,7 @@ describe('renderChart → PNG', () => { expect(res.buffer).toBeInstanceOf(Buffer); expect(res.buffer!.length).toBeGreaterThan(1000); expect(res.buffer!.subarray(0, 4).equals(PNG_MAGIC)).toBe(true); + expect(pngDimensions(res.buffer!)).toEqual({ width: res.width, height: res.height }); expect(res.base64).toBeTruthy(); expect(Array.isArray(res.warnings)).toBe(true); }); diff --git a/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts index 39361c8c..d5b09be4 100644 --- a/packages/flint-mcp/tests/server.test.ts +++ b/packages/flint-mcp/tests/server.test.ts @@ -116,6 +116,25 @@ describe('MCP server', () => { expect(payload.valid).toBe(true); }); + it('validate_chart rejects malformed specs before assembly', async () => { + for (const chart_spec of [ + { ...barChart.chart_spec, encodings: {} }, + { ...barChart.chart_spec, encodings: { x: { field: 'missing' }, y: { field: 'revenue' } } }, + { + ...barChart.chart_spec, + encodings: { x: { field: 'region' }, y: { field: 'revenue' }, banana: { field: 'region' } }, + }, + ]) { + const res: any = await client.callTool({ + name: 'validate_chart', + arguments: { ...barChart, chart_spec, backend: 'vegalite' }, + }); + const payload = JSON.parse(res.content[0].text); + expect(payload.valid).toBe(false); + expect(payload.errors.length).toBeGreaterThan(0); + } + }); + it('list_chart_types enumerates chart types per backend', async () => { const res: any = await client.callTool({ name: 'list_chart_types', diff --git a/packages/flint-mcp/ui/src/FlintApp.tsx b/packages/flint-mcp/ui/src/FlintApp.tsx index ee174592..043a4e44 100644 --- a/packages/flint-mcp/ui/src/FlintApp.tsx +++ b/packages/flint-mcp/ui/src/FlintApp.tsx @@ -23,6 +23,8 @@ import { type ResolvedAction, } from './options'; +declare const __FLINT_MCP_VERSION__: string; + /** Control descriptor shared by chart properties and encoding actions. */ type ControlSpec = | { type: 'continuous'; min: number; max: number; step?: number } @@ -384,7 +386,7 @@ export function FlintApp() { const [hostContext, setHostContext] = useState(); const { app, error } = useApp({ - appInfo: { name: 'Flint Chart', version: '0.1.0' }, + appInfo: { name: 'Flint Chart', version: __FLINT_MCP_VERSION__ }, capabilities: {}, autoResize: true, onAppCreated: (app) => { diff --git a/packages/flint-mcp/ui/vite.config.ts b/packages/flint-mcp/ui/vite.config.ts index eb6cead8..1893aac1 100644 --- a/packages/flint-mcp/ui/vite.config.ts +++ b/packages/flint-mcp/ui/vite.config.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; @@ -8,12 +9,18 @@ import react from '@vitejs/plugin-react'; import { viteSingleFile } from 'vite-plugin-singlefile'; const root = dirname(fileURLToPath(import.meta.url)); +const packageJson = JSON.parse(readFileSync(resolve(root, '../package.json'), 'utf8')) as { + version: string; +}; // Bundle the entire app (React + Flint + Vega) into one self-contained HTML so // the MCP App resource needs no network access and renders fully client-side. export default defineConfig({ root, plugins: [react(), viteSingleFile()], + define: { + __FLINT_MCP_VERSION__: JSON.stringify(packageJson.version), + }, // The ext-apps React hooks (useApp) and the app share one React instance. // Without deduping, the bundle pulls in a second React copy whose hook // dispatcher is never set, so useState() throws at runtime. diff --git a/site/src/components/ResizeSplit.tsx b/site/src/components/ResizeSplit.tsx index 7c37c7c9..559ea3db 100644 --- a/site/src/components/ResizeSplit.tsx +++ b/site/src/components/ResizeSplit.tsx @@ -5,6 +5,7 @@ type SplitDirection = 'horizontal' | 'vertical'; interface ResizeSplitProps { direction: SplitDirection; + className?: string; /** Initial size of the first pane, as a percentage (0–100). */ initialRatio?: number; minFirst?: number; @@ -35,6 +36,7 @@ function clampRatio(value: number, minFirst: number, minSecond: number) { */ export function ResizeSplit({ direction, + className, initialRatio = 50, minFirst = 15, minSecond = 15, @@ -104,6 +106,7 @@ export function ResizeSplit({ return (
-