Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/flint-js/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/flint-mcp/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
89 changes: 88 additions & 1 deletion packages/flint-mcp/src/render/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
assembleECharts,
assembleChartjs,
type ChartAssemblyInput,
type ChartEncoding,
type ChartTemplateDef,
type ChartWarning,
vlGetTemplateDef,
ecGetTemplateDef,
cjsGetTemplateDef,
} from 'flint-chart';
import {
resolveDataSource,
Expand All @@ -29,6 +34,12 @@ const ASSEMBLERS: Record<
chartjs: assembleChartjs,
};

const TEMPLATE_LOOKUP: Record<RenderBackend, (chartType: string) => ChartTemplateDef | undefined> = {
vegalite: vlGetTemplateDef,
echarts: ecGetTemplateDef,
chartjs: cjsGetTemplateDef,
};

export interface AssembleResult {
/** The backend-native spec (still carrying Flint's private `_`-keys). */
spec: any;
Expand Down Expand Up @@ -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');
Expand All @@ -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) {
Expand All @@ -98,6 +114,77 @@ export function prepareInput(
return resolvedInput;
}

function validateChartSpec(cs: any, rows: Record<string, unknown>[], 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
Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions packages/flint-mcp/src/render/vegalite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<svg[^>]*\\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;
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion packages/flint-mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions packages/flint-mcp/tests/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
});
Expand Down
19 changes: 19 additions & 0 deletions packages/flint-mcp/tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion packages/flint-mcp/ui/src/FlintApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -384,7 +386,7 @@ export function FlintApp() {
const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>();

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) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/flint-mcp/ui/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
// 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';
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.
Expand Down
3 changes: 3 additions & 0 deletions site/src/components/ResizeSplit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,6 +36,7 @@ function clampRatio(value: number, minFirst: number, minSecond: number) {
*/
export function ResizeSplit({
direction,
className,
initialRatio = 50,
minFirst = 15,
minSecond = 15,
Expand Down Expand Up @@ -104,6 +106,7 @@ export function ResizeSplit({
return (
<div
ref={containerRef}
className={className}
style={{
display: 'flex',
flexDirection: isHorizontal ? 'row' : 'column',
Expand Down
6 changes: 4 additions & 2 deletions site/src/components/SiteShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ export function SiteNavBar(_props: { flush?: boolean } = {}) {

return (
<header
className="site-nav-bar"
style={{
display: 'flex',
alignItems: 'center',
gap: 20,
width: '100%',
boxSizing: 'border-box',
maxWidth: CONTENT_MAX_WIDTH,
margin: '0 auto',
padding: '0 20px',
Expand All @@ -45,7 +47,7 @@ export function SiteNavBar(_props: { flush?: boolean } = {}) {
>
<BrandLink />

<nav style={{ display: 'flex', alignItems: 'center', gap: 16, flex: 1 }}>
<nav className="site-nav-scroll" style={{ display: 'flex', alignItems: 'center', gap: 16, flex: 1, minWidth: 0 }}>
<NavLink to="/" active={pathname === '/'}>
About
</NavLink>
Expand All @@ -70,7 +72,7 @@ export function SiteNavBar(_props: { flush?: boolean } = {}) {
<NavLinkExternal href={`${GITHUB_REPO}#ecosystem`} label="Ecosystem" /> */}
</nav>

<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="site-nav-actions" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<GitHubLink />
</div>
</header>
Expand Down
Loading
Loading