diff --git a/package-lock.json b/package-lock.json index b7958def..5a4e36e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10340,6 +10340,7 @@ "zod": "^3.25.1" }, "bin": { + "flint-chart": "dist/flint-chart.js", "flint-chart-mcp": "dist/cli.js" }, "devDependencies": { diff --git a/packages/flint-mcp/README.md b/packages/flint-mcp/README.md index ae31bcde..4e1ef0f8 100644 --- a/packages/flint-mcp/README.md +++ b/packages/flint-mcp/README.md @@ -176,6 +176,21 @@ deployment, reject local file references and accept only inline rows: npx -y flint-chart-mcp --disable-file-reference ``` +### Local file compile (`flint-chart`) + +Compile a saved `ChartAssemblyInput` JSON to SVG or PNG without an agent: + +```bash +flint-chart compile chart.json --format svg +flint-chart compile chart.json --backend echarts --format png --output chart.png +cat chart.json | flint-chart compile - --format svg > chart.svg +flint-chart chart.json --format svg --output chart.svg # shorthand, compile is optional +``` + +Options: `--backend ` (default `vegalite`), `--format ` (default `svg` except `chartjs` → `png`), `--output ` / `-o ` (`-` for stdout; default `.` next to input, stdout when input is `-`), `--scale <0.5–4>`, `--background `, `-h/--help`, `-v/--version`. + +Relative `data.url` paths in the input resolve against the input file's directory, or the current working directory when reading from stdin (`-`). + ## Example `render_chart` call ```jsonc diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index efd74d77..06556d16 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -28,7 +28,8 @@ }, "type": "module", "bin": { - "flint-chart-mcp": "dist/cli.js" + "flint-chart-mcp": "dist/cli.js", + "flint-chart": "dist/flint-chart.js" }, "main": "./dist/server.js", "types": "./dist/server.d.ts", diff --git a/packages/flint-mcp/src/cli.ts b/packages/flint-mcp/src/cli.ts index 3b2af32b..b773ba64 100644 --- a/packages/flint-mcp/src/cli.ts +++ b/packages/flint-mcp/src/cli.ts @@ -2,7 +2,8 @@ // Licensed under the MIT License. import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { createServer, resolveBackends, VERSION } from './server.js'; +import { createServer, resolveBackends } from './server.js'; +import { VERSION } from './version.js'; import { startHttpServer, DEFAULT_MCP_PATH } from './http.js'; import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; @@ -12,7 +13,7 @@ MCP server that compiles and renders Flint chart specs to Vega-Lite, ECharts, or Chart.js artifacts (PNG/SVG), entirely in-process. Usage: - flint-chart-mcp [options] + flint-chart-mcp [options] Start the MCP server (stdio by default) Options: --transport Transport to use. Default: stdio. @@ -25,7 +26,7 @@ Options: --allowed-hosts Comma-separated Host header allowlist enabling DNS-rebinding protection (http transport only). --allowed-origins Comma-separated Origin header allowlist enabling - DNS-rebinding protection (http transport only). + DNS-rebinding protection (http transport only). --backends Comma-separated backends to expose (subset of: ${SUPPORTED_BACKENDS.join(', ')}). Overridden by the FLINT_MCP_BACKENDS env var if set. @@ -54,18 +55,21 @@ Prompts: Example MCP client config: { "command": "npx", "args": ["-y", "flint-chart-mcp"] } + +Local file compile (no agent needed): + flint-chart compile chart.json --format svg (via the separate "flint-chart" binary) + See "flint-chart --help" for compile options. `; +// keep runCompile re-export for existing tests importing from cli.js +export { runCompile, type CompileIo } from './compile.js'; + interface ParsedArgs { transport: string; backends?: SupportedBackend[]; - /** When true, reject local data.url file references (inline rows only). */ disableFileReference: boolean; - /** True when --disable-file-reference was explicitly passed on the CLI. */ disableFileReferenceSet: boolean; - /** True when a deprecated --data-root(s) flag was passed (ignored, warned). */ usedDeprecatedDataRoots: boolean; - /** HTTP transport options. */ port?: number; host?: string; path?: string; @@ -75,24 +79,16 @@ interface ParsedArgs { function parseBackends(raw: string | undefined): SupportedBackend[] | undefined { if (!raw) return undefined; - const list = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) as SupportedBackend[]; + const list = raw.split(',').map((s) => s.trim()).filter(Boolean) as SupportedBackend[]; return list.length ? list : undefined; } -/** Split a comma-separated allowlist into trimmed entries. */ function parseList(raw: string | undefined): string[] | undefined { if (!raw) return undefined; - const list = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); + const list = raw.split(',').map((s) => s.trim()).filter(Boolean); return list.length ? list : undefined; } -/** 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(); @@ -147,28 +143,19 @@ function parseArgs(argv: string[]): ParsedArgs { break; case '--data-roots': case '--data-root': - // 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('--port=')) { - out.port = Number(arg.slice('--port='.length)); - } else if (arg.startsWith('--host=')) { - out.host = arg.slice('--host='.length); - } else if (arg.startsWith('--path=')) { - out.path = arg.slice('--path='.length); - } else if (arg.startsWith('--allowed-hosts=')) { - out.allowedHosts = parseList(arg.slice('--allowed-hosts='.length)); - } else if (arg.startsWith('--allowed-origins=')) { - out.allowedOrigins = parseList(arg.slice('--allowed-origins='.length)); - } else if (arg.startsWith('--backends=')) { - out.backends = parseBackends(arg.slice('--backends='.length)); - } else if (arg.startsWith('--data-roots=') || arg.startsWith('--data-root=')) { - out.usedDeprecatedDataRoots = true; - } else { + if (arg.startsWith('--transport=')) out.transport = arg.slice('--transport='.length); + else if (arg.startsWith('--port=')) out.port = Number(arg.slice('--port='.length)); + else if (arg.startsWith('--host=')) out.host = arg.slice('--host='.length); + else if (arg.startsWith('--path=')) out.path = arg.slice('--path='.length); + else if (arg.startsWith('--allowed-hosts=')) out.allowedHosts = parseList(arg.slice('--allowed-hosts='.length)); + else if (arg.startsWith('--allowed-origins=')) out.allowedOrigins = parseList(arg.slice('--allowed-origins='.length)); + else if (arg.startsWith('--backends=')) out.backends = parseBackends(arg.slice('--backends='.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); } @@ -182,46 +169,24 @@ async function main(): Promise { const transport = (process.env.FLINT_MCP_TRANSPORT?.trim() || args.transport).toLowerCase(); if (transport !== 'stdio' && transport !== 'http') { - process.stderr.write( - `Unsupported transport "${transport}". Use "stdio" or "http".\n`, - ); + process.stderr.write(`Unsupported transport "${transport}". Use "stdio" or "http".\n`); process.exit(2); } - // Env var takes precedence over the flag for deployment-time gating. - const enabledBackends = - parseBackends(process.env.FLINT_MCP_BACKENDS) ?? args.backends; + const enabledBackends = parseBackends(process.env.FLINT_MCP_BACKENDS) ?? args.backends; const envDisable = parseBoolEnv(process.env.FLINT_MCP_DISABLE_FILE_REFERENCE); - // The http transport is remote: local files belong to the server, not the - // user, so default to blocking file references unless explicitly overridden. - const disableFileReference = - envDisable ?? (args.disableFileReferenceSet ? args.disableFileReference : transport === 'http'); + const disableFileReference = envDisable ?? (args.disableFileReferenceSet ? args.disableFileReference : transport === 'http'); - // 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', + '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 dataMode = disableFileReference - ? 'local file references disabled' - : 'local files readable on request'; + const dataMode = disableFileReference ? 'local file references disabled' : 'local files readable on request'; if (transport === 'http') { - // Some hosts (e.g. Azure App Service custom containers) inject an empty - // PORT env var that would override the intended port; treat blank env - // values as unset so the flag/default still applies. const portEnv = process.env.PORT?.trim() || process.env.FLINT_MCP_PORT?.trim(); const port = Number(portEnv || args.port || 8080); if (!Number.isFinite(port) || port <= 0) { @@ -238,10 +203,7 @@ async function main(): Promise { allowedHosts: args.allowedHosts, allowedOrigins: args.allowedOrigins, }); - process.stderr.write( - `flint-chart-mcp ${VERSION} listening on ${running.url} ` + - `(backends: ${resolved.join(', ')}; ${dataMode})\n`, - ); + process.stderr.write(`flint-chart-mcp ${VERSION} listening on ${running.url} (backends: ${resolved.join(', ')}; ${dataMode})\n`); const shutdown = () => { void running.close().finally(() => process.exit(0)); }; @@ -253,16 +215,10 @@ async function main(): Promise { const server = createServer({ enabledBackends, disableFileReference }); const stdio = new StdioServerTransport(); await server.connect(stdio); - - // stdout is the protocol channel; log to stderr only. - process.stderr.write( - `flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}; ` + - `${dataMode})\n`, - ); + process.stderr.write(`flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}; ${dataMode})\n`); } 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/compile.ts b/packages/flint-mcp/src/compile.ts new file mode 100644 index 00000000..f1a965ee --- /dev/null +++ b/packages/flint-mcp/src/compile.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, extname, resolve as resolvePath } from 'node:path'; +import { VERSION } from './version.js'; +import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; +import { renderChart } from './render/index.js'; +import type { RenderBackend, RenderFormat } from './render/types.js'; + +const COMPILE_HELP = `flint-chart ${VERSION} + +Compile a saved Flint ChartAssemblyInput JSON to SVG or PNG, entirely in-process. + +Usage: + flint-chart compile [options] + flint-chart [options] (shorthand, same as compile) + +Arguments: + Path to JSON file containing ChartAssemblyInput, or "-" for stdin. + +Options: + --backend Rendering backend: ${SUPPORTED_BACKENDS.join(', ')}. Default: vegalite. + --format Output format. Default: svg (vegalite/echarts) or png (chartjs). + --output , -o + Output file. Default: . next to input (chart.json → chart.svg). + Use "-" for stdout. Defaults to stdout when input is stdin and no output given. + --scale Device scale for PNG (0.5–4). Default: 1. + --background Background color. Default: #ffffff. + -h, --help Print this help and exit. + -v, --version Print version and exit. + +Note: + Relative data.url paths in the input resolve against the input file's + directory, or against the current working directory when reading from stdin. + +Examples: + flint-chart compile chart.json --format svg + flint-chart compile chart.json --backend echarts --format png --output chart.png + cat chart.json | flint-chart compile - --format svg > chart.svg + flint-chart chart.json --format svg --output chart.svg +`; + +interface CompileOptions { + input: string; + backend: RenderBackend; + format: RenderFormat; + output?: string; + scale?: number; + background?: string; +} + +type CompileParseResult = + | { kind: 'run'; options: CompileOptions } + | { kind: 'help' } + | { kind: 'version' }; + +class CompileError extends Error { + constructor(message: string, readonly exitCode: number = 2) { + super(message); + } +} + +function parseCompileArgs(argv: string[]): CompileParseResult { + let input: string | undefined; + let backend: string | undefined; + let format: string | undefined; + let output: string | undefined; + let scale: number | undefined; + let background: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + return { kind: 'help' }; + } else if (arg === '-v' || arg === '--version') { + return { kind: 'version' }; + } else if (arg === '--backend') { + backend = argv[++i]; + if (!backend) throw new CompileError('Missing value for --backend'); + } else if (arg.startsWith('--backend=')) { + backend = arg.slice('--backend='.length); + } else if (arg === '--format') { + format = argv[++i]; + if (!format) throw new CompileError('Missing value for --format'); + } else if (arg.startsWith('--format=')) { + format = arg.slice('--format='.length); + } else if (arg === '--output' || arg === '-o') { + output = argv[++i]; + if (!output) throw new CompileError('Missing value for --output'); + } else if (arg.startsWith('--output=')) { + output = arg.slice('--output='.length); + } else if (arg.startsWith('-o') && arg.length > 2 && !arg.startsWith('-output')) { + output = arg.slice(2); + } else if (arg === '--scale') { + const raw = argv[++i]; + scale = Number(raw); + if (!Number.isFinite(scale)) throw new CompileError(`Invalid --scale value: ${raw}`); + } else if (arg.startsWith('--scale=')) { + scale = Number(arg.slice('--scale='.length)); + if (!Number.isFinite(scale)) throw new CompileError(`Invalid --scale value: ${arg.slice('--scale='.length)}`); + } else if (arg === '--background') { + background = argv[++i]; + if (!background) throw new CompileError('Missing value for --background'); + } else if (arg.startsWith('--background=')) { + background = arg.slice('--background='.length); + } else if (arg === '-') { + if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); + input = arg; + } else if (arg.startsWith('-')) { + throw new CompileError(`Unknown compile option: ${arg}\nRun "flint-chart --help" for usage.`); + } else { + if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); + input = arg; + } + } + + if (!input) throw new CompileError('Missing argument.\nRun "flint-chart --help" for usage.'); + + const resolvedBackend = (backend ?? 'vegalite') as RenderBackend; + if (!SUPPORTED_BACKENDS.includes(resolvedBackend as SupportedBackend)) { + throw new CompileError(`Unsupported backend "${resolvedBackend}". Choose one of: ${SUPPORTED_BACKENDS.join(', ')}`); + } + + let resolvedFormat: RenderFormat; + if (format) { + const f = format.toLowerCase() as RenderFormat; + if (f !== 'png' && f !== 'svg') throw new CompileError(`Unsupported format "${format}". Use "png" or "svg".`); + resolvedFormat = f; + } else { + resolvedFormat = resolvedBackend === 'chartjs' ? 'png' : 'svg'; + } + + if (resolvedBackend === 'chartjs' && resolvedFormat === 'svg') { + throw new CompileError('the chartjs backend supports png output only (no SVG engine); request format "png"'); + } + + if (scale !== undefined && (!Number.isFinite(scale) || scale < 0.5 || scale > 4)) { + throw new CompileError(`Invalid --scale ${scale}: must be between 0.5 and 4`); + } + + return { + kind: 'run', + options: { input, backend: resolvedBackend, format: resolvedFormat, output, scale, background }, + }; +} + +export interface CompileIo { + readStdin(): string; + stdout(data: string | Buffer): void; + stderr(line: string): void; +} + +const defaultCompileIo: CompileIo = { + readStdin: () => readFileSync(0, 'utf8'), + stdout: (data) => process.stdout.write(data), + stderr: (line) => process.stderr.write(line), +}; + +function readInputJson(inputPath: string, io: CompileIo): { json: unknown; cwd: string | undefined } { + let raw: string; + let cwd: string | undefined; + if (inputPath === '-') { + try { + raw = io.readStdin(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Failed to read stdin: ${msg}`, 1); + } + } else { + const abs = resolvePath(inputPath); + try { + raw = readFileSync(abs, 'utf8'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Failed to read input file "${inputPath}": ${msg}`, 1); + } + cwd = dirname(abs); + } + try { + return { json: JSON.parse(raw), cwd }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Invalid JSON in "${inputPath}": ${msg}`, 1); + } +} + +function resolveOutputPath(input: string, explicitOutput: string | undefined, format: RenderFormat): string | undefined { + if (explicitOutput) { + if (explicitOutput === '-') return undefined; + return resolvePath(explicitOutput); + } + if (input === '-') return undefined; + const absInput = resolvePath(input); + const dir = dirname(absInput); + const base = basename(absInput, extname(absInput)); + const outBase = base || 'chart'; + return resolvePath(dir, `${outBase}.${format}`); +} + +export async function runCompile(argv: string[], io: CompileIo = defaultCompileIo): Promise { + let parsed: CompileParseResult; + try { + parsed = parseCompileArgs(argv); + } catch (err) { + if (err instanceof CompileError) { + io.stderr(`${err.message}\n`); + return err.exitCode; + } + throw err; + } + + if (parsed.kind === 'help') { + io.stdout(COMPILE_HELP); + return 0; + } + if (parsed.kind === 'version') { + io.stdout(`${VERSION}\n`); + return 0; + } + + const opts = parsed.options; + let json: unknown; + let cwd: string | undefined; + try { + ({ json, cwd } = readInputJson(opts.input, io)); + } catch (err) { + if (err instanceof CompileError) { + io.stderr(`${err.message}\n`); + return err.exitCode; + } + throw err; + } + + const input = json as Record; + if (input == null || typeof input !== 'object' || !('chart_spec' in input) || !('data' in input)) { + io.stderr( + 'Input JSON must be a ChartAssemblyInput with at least { data, chart_spec }.\n' + + 'Example: { "data": { "values": [...] }, "chart_spec": { "chartType": "Bar Chart", "encodings": { "x": { "field": "a" }, "y": { "field": "b" } } } }\n', + ); + return 2; + } + + let result: Awaited>; + try { + result = await renderChart(input as any, opts.backend, { + format: opts.format, + scale: opts.scale, + background: opts.background, + cwd, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`Compile failed: ${msg}\n`); + return 1; + } + + for (const w of result.warnings) io.stderr(`warning [${w.code}]: ${w.message}\n`); + + const outPath = resolveOutputPath(opts.input, opts.output, opts.format); + try { + if (result.format === 'svg') { + const svg = result.svg ?? ''; + if (outPath) { + writeFileSync(outPath, svg, 'utf8'); + io.stderr(`Wrote ${result.backend} · ${result.format} · ${result.width}×${result.height}px → ${outPath}\n`); + } else { + io.stdout(svg); + } + } else { + const buffer = result.buffer!; + if (outPath) { + writeFileSync(outPath, buffer); + io.stderr(`Wrote ${result.backend} · ${result.format} · ${result.width}×${result.height}px → ${outPath}\n`); + } else { + io.stdout(buffer); + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`Failed to write output: ${msg}\n`); + return 1; + } + return 0; +} + + diff --git a/packages/flint-mcp/src/flint-chart.ts b/packages/flint-mcp/src/flint-chart.ts new file mode 100644 index 00000000..d4eada09 --- /dev/null +++ b/packages/flint-mcp/src/flint-chart.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCompile } from './compile.js'; + +async function main(): Promise { + const raw = process.argv.slice(2); + if (raw.length === 0 || raw[0] === '-h' || raw[0] === '--help' || raw[0] === '-v' || raw[0] === '--version') { + const code = await runCompile(raw); + process.exit(code); + } + const argv = raw[0] === 'compile' ? raw.slice(1) : raw; + const code = await runCompile(argv); + process.exit(code); +} + +const isEntry = process.argv[1] !== undefined && (() => { + try { + return resolvePath(process.argv[1]) === resolvePath(fileURLToPath(import.meta.url)); + } catch { + return false; + } +})(); + +if (isEntry) { + main().catch((err) => { + process.stderr.write(`flint-chart failed: ${err?.stack ?? err}\n`); + process.exit(1); + }); +} diff --git a/packages/flint-mcp/src/render/data-source.ts b/packages/flint-mcp/src/render/data-source.ts index 911eca5f..f680c339 100644 --- a/packages/flint-mcp/src/render/data-source.ts +++ b/packages/flint-mcp/src/render/data-source.ts @@ -20,6 +20,12 @@ export interface DataSourceOptions { maxDataFileBytes?: number; /** Row-count guard after loading inline or referenced data. */ maxDataRows?: number; + /** + * Base directory for resolving relative `data.url` paths. Defaults to the + * current working directory. The CLI passes the input file's directory so a + * hand-edited `chart.json` can reference `./data.csv` next to it. + */ + cwd?: string; } /** @@ -68,7 +74,7 @@ export function resolveDataSource( ); } - const filePath = resolveTrustedDataPath(data.url); + const filePath = resolveTrustedDataPath(data.url, options.cwd); const rows = readLocalRows(filePath, options); return { ...input, data: { values: rows } } as ChartAssemblyInput; } @@ -80,11 +86,11 @@ function isRemoteReference(rawUrl: string): boolean { /** * 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. + * governs the agent's file access. Relative references resolve against `cwd` (or + * the working directory when not specified). */ -function resolveTrustedDataPath(rawUrl: string): string { - const candidatePaths = trustedReferenceToPaths(rawUrl.trim()); +function resolveTrustedDataPath(rawUrl: string, cwd?: string): string { + const candidatePaths = trustedReferenceToPaths(rawUrl.trim(), cwd); let lastError: unknown; for (const candidatePath of candidatePaths) { try { @@ -105,7 +111,7 @@ function resolveTrustedDataPath(rawUrl: string): string { ); } -function trustedReferenceToPaths(rawReference: string): string[] { +function trustedReferenceToPaths(rawReference: string, cwd?: string): string[] { if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(rawReference)) { const parsedUrl = new URL(rawReference); if (parsedUrl.protocol !== 'file:') { @@ -116,7 +122,9 @@ function trustedReferenceToPaths(rawReference: string): string[] { } return [fileURLToPath(parsedUrl)]; } - // Absolute paths are used as given; relative paths resolve against cwd. + // Absolute paths are used as given; relative paths resolve against cwd when + // given, or the process working directory otherwise. + if (cwd) return [resolvePath(cwd, rawReference)]; return [resolvePath(rawReference)]; } diff --git a/packages/flint-mcp/src/render/index.ts b/packages/flint-mcp/src/render/index.ts index f5ab60f4..439d4e5b 100644 --- a/packages/flint-mcp/src/render/index.ts +++ b/packages/flint-mcp/src/render/index.ts @@ -71,6 +71,7 @@ export async function renderChart( const { spec, warnings, width, height } = assembleForBackend(backend, input, { disableFileReference: options.disableFileReference, + cwd: options.cwd, }); // 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 4955a230..35cfcbad 100644 --- a/packages/flint-mcp/src/render/types.ts +++ b/packages/flint-mcp/src/render/types.ts @@ -21,6 +21,8 @@ export interface RenderOptions { background?: string; /** When true, reject local `data.url` file references (inline rows only). */ disableFileReference?: boolean; + /** Base directory for resolving relative `data.url` paths. Defaults to cwd. */ + cwd?: string; } /** 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 8ea3f516..9d073b41 100644 --- a/packages/flint-mcp/src/server.ts +++ b/packages/flint-mcp/src/server.ts @@ -23,10 +23,8 @@ import { type AssemblyInputArgs, } from './tools/schemas.js'; -/** Package version, kept in lockstep with the npm release. */ -export const VERSION = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), -).version as string; +import { VERSION } from './version.js'; +export { VERSION }; export const AGENT_SKILL_RESOURCE_URI = 'flint://agent-skill'; export const THEME_SKILL_RESOURCE_URI = 'flint://theme-skill'; diff --git a/packages/flint-mcp/src/version.ts b/packages/flint-mcp/src/version.ts new file mode 100644 index 00000000..c58c8acf --- /dev/null +++ b/packages/flint-mcp/src/version.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; + +export const VERSION: string = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version as string; diff --git a/packages/flint-mcp/tests/compile.test.ts b/packages/flint-mcp/tests/compile.test.ts new file mode 100644 index 00000000..d351f2c7 --- /dev/null +++ b/packages/flint-mcp/tests/compile.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runCompile, type CompileIo } from '../src/cli.js'; + +function chartInput(data: unknown): string { + return JSON.stringify({ + data, + semantic_types: { region: 'Category', revenue: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'Revenue by region', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + }, + }); +} + +const CSV = 'region,revenue\nNorth,120\nSouth,90\nEast,150\n'; + +interface IoHarness { + io: CompileIo; + stdout(): Buffer; + stdoutText(): string; + stderrText(): string; + setStdin(text: string): void; +} + +function makeIo(): IoHarness { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdinText = ''; + return { + io: { + readStdin: () => stdinText, + stdout: (data) => stdoutChunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)), + stderr: (line) => stderrChunks.push(Buffer.from(line)), + }, + stdout: () => Buffer.concat(stdoutChunks), + stdoutText: () => Buffer.concat(stdoutChunks).toString('utf8'), + stderrText: () => Buffer.concat(stderrChunks).toString('utf8'), + setStdin: (text) => { + stdinText = text; + }, + }; +} + +let root: string; + +beforeEach(() => { + // realpathSync so macOS /var → /private/var symlink doesn't surprise path + // resolution in stderr assertions. + root = realpathSync(mkdtempSync(join(tmpdir(), 'flint-cli-'))); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('compile: argument parsing', () => { + it('prints help and exits 0 for --help / -h', async () => { + for (const flag of ['--help', '-h']) { + const harness = makeIo(); + expect(await runCompile([flag], harness.io)).toBe(0); + expect(harness.stdoutText()).toContain('flint-chart compile'); + } + }); + + it('help documents data.url resolution', async () => { + const harness = makeIo(); + await runCompile(['--help'], harness.io); + expect(harness.stdoutText()).toMatch(/data\.url/i); + expect(harness.stdoutText()).toMatch(/working directory when reading from stdin/i); + }); + + it('prints version and exits 0 for --version / -v', async () => { + for (const flag of ['--version', '-v']) { + const harness = makeIo(); + expect(await runCompile([flag], harness.io)).toBe(0); + expect(harness.stdoutText().trim()).toMatch(/^\d+\.\d+\.\d+/); + } + }); + + it('errors with exit 2 when input is missing', async () => { + const harness = makeIo(); + expect(await runCompile([], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Missing argument.'); + }); + + it('errors with exit 2 on unknown options', async () => { + const harness = makeIo(); + expect(await runCompile(['--bogus', 'x.json'], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Unknown compile option: --bogus'); + }); + + it('rejects a single-dash "-output" typo as an unknown option (exit 2)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile(['-output', 'x.svg', chartPath], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Unknown compile option: -output'); + }); + + it('still accepts the joined -o form', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + const outPath = join(root, 'joined.svg'); + const harness = makeIo(); + expect(await runCompile([`-o${outPath}`, chartPath], harness.io)).toBe(0); + expect(readFileSync(outPath, 'utf8')).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile([chartPath, '--backend', 'nope'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--format', 'gif'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--scale', '9'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--scale', 'abc'], harness.io)).toBe(2); + }); + + it('rejects chartjs with svg output (exit 2)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile([chartPath, '--backend', 'chartjs', '--format', 'svg'], harness.io)).toBe(2); + expect(harness.stderrText()).toMatch(/chartjs backend supports png output only/); + }); +}); + +describe('compile: input reading and exit codes', () => { + it('errors with exit 1 for a missing input file', async () => { + const harness = makeIo(); + expect(await runCompile([join(root, 'missing.json')], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Failed to read input file'); + }); + + it('errors with exit 1 for invalid JSON', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, '{not json'); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Invalid JSON'); + }); + + it('errors with exit 2 when the JSON is not a ChartAssemblyInput', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, JSON.stringify({ hello: 'world' })); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('ChartAssemblyInput'); + }); + + it('reports render failures with exit 1 (remote data.url)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ url: 'https://example.com/sales.csv' })); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Compile failed'); + }); +}); + +describe('compile: rendering and output', () => { + it('resolves relative data.url against the input file directory and writes .svg', async () => { + writeFileSync(join(root, 'sales.csv'), CSV); + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ url: 'sales.csv' })); + + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(0); + expect(harness.stdoutText()).toBe(''); // written to file, not stdout + + const svgPath = join(root, 'chart.svg'); + const svg = readFileSync(svgPath, 'utf8'); + expect(svg).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + + const harness = makeIo(); + expect(await runCompile([chartPath, '--format', 'png'], harness.io)).toBe(0); + const pngPath = join(root, 'chart.png'); + const bytes = readFileSync(pngPath); + expect(bytes.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + expect(harness.stderrText()).toContain('Wrote vegalite · png'); + }); + + it('writes to stdout with -o - or --output -', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + + for (const flag of ['-o', '--output']) { + const harness = makeIo(); + expect(await runCompile([chartPath, flag, '-'], harness.io)).toBe(0); + expect(harness.stdoutText()).toContain(' { + writeFileSync(join(root, 'sales.csv'), CSV); + const harness = makeIo(); + harness.setStdin(chartInput({ url: 'sales.csv' })); + + const previousCwd = process.cwd(); + try { + process.chdir(root); + expect(await runCompile(['-'], harness.io)).toBe(0); + } finally { + process.chdir(previousCwd); + } + expect(harness.stdoutText()).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + const outPath = join(root, 'custom', 'result.svg'); + mkdirSync(join(root, 'custom'), { recursive: true }); + + const harness = makeIo(); + expect(await runCompile([chartPath, '--output', outPath], harness.io)).toBe(0); + expect(readFileSync(outPath, 'utf8')).toContain('