Skip to content

Commit 88b1b5e

Browse files
committed
fix(create-chart): clarify chart spec metadata
1 parent 960d598 commit 88b1b5e

6 files changed

Lines changed: 41 additions & 6 deletions

File tree

mcp/create-chart/src/handler.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ describe("handleRenderChart", () => {
209209
// up and mangle.
210210
expect(ready).not.toMatch(/\\"/);
211211
expect(meta.type).toBe("pie");
212+
expect(meta.schema_version).toBe(1);
213+
expect(meta.artifact_kind).toBe("chart_spec");
212214
expect(typeof meta.chart_id).toBe("string");
213215
expect((meta.chart_id as string).startsWith("pie-")).toBe(true);
214216
expect(typeof meta.bytes).toBe("number");
@@ -232,6 +234,7 @@ describe("handleRenderChart", () => {
232234
const inner = ready.replace(/^```chart\n/, "").replace(/\n```$/, "");
233235
const spec = JSON.parse(inner);
234236
expect(spec.type).toBe("bar");
237+
expect(spec.schema_version).toBe(1);
235238
expect(spec.data.series[0].values).toEqual([10, 20]);
236239
expect(spec.title).toBe("Demo");
237240
expect(spec).not.toHaveProperty("extra_garbage");
@@ -247,10 +250,11 @@ describe("handleRenderChart", () => {
247250
const expectedDir = path.resolve(tmp, "chart-render");
248251
expect(existsSync(expectedDir)).toBe(true);
249252
const expectedFile = path.join(expectedDir, `${meta.chart_id as string}.json`);
250-
expect(meta.svg_path).toBe(expectedFile);
253+
expect(meta.svg_path).toBe("");
251254
expect(meta.spec_path).toBe(expectedFile);
252255
expect(existsSync(expectedFile)).toBe(true);
253256
const onDisk = JSON.parse(readFileSync(expectedFile, "utf8"));
257+
expect(onDisk.schema_version).toBe(1);
254258
expect(onDisk.type).toBe("line");
255259
expect(onDisk.data.series[0].points).toEqual([{ x: 1, y: 2 }]);
256260
expect(readdirSync(expectedDir)).toContain(`${meta.chart_id as string}.json`);

mcp/create-chart/src/handler.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { mkdir, writeFile } from "node:fs/promises";
22
import path from "node:path";
33
import type { RenderChartArgs, RenderChartResult } from "./types.js";
44

5+
const CHART_SPEC_VERSION = 1;
6+
57
export const RENDER_CHART_INPUT_SCHEMA = {
68
type: "object",
79
required: ["type", "data"],
@@ -46,7 +48,7 @@ export async function handleRenderChart(rawArgs: unknown): Promise<{
4648
const args = validate(rawArgs);
4749
const id = newChartId(args.type);
4850

49-
const spec = JSON.stringify(args);
51+
const spec = JSON.stringify({ ...args, schema_version: CHART_SPEC_VERSION });
5052
const markdownEmbed = "```chart\n" + spec + "\n```";
5153

5254
let specPath: string | undefined;
@@ -60,10 +62,12 @@ export async function handleRenderChart(rawArgs: unknown): Promise<{
6062
}
6163

6264
const result: RenderChartResult = {
65+
schema_version: CHART_SPEC_VERSION,
6366
chart_id: id,
6467
type: args.type,
68+
artifact_kind: "chart_spec",
6569
spec_path: specPath ?? "",
66-
svg_path: specPath ?? "",
70+
svg_path: "",
6771
bytes: Buffer.byteLength(spec, "utf8"),
6872
embed_instructions:
6973
"Paste the READY_TO_PASTE block above verbatim into your reply where the chart should appear. Do not modify the JSON, add backslashes, escape non-ASCII characters, convert to ```svg, or inline an <img>.",

mcp/create-chart/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface LineSeries {
1919
}
2020

2121
export interface ChartCommonOpts {
22+
schema_version?: 1;
2223
title?: string;
2324
width?: number;
2425
height?: number;
@@ -35,9 +36,15 @@ export type RenderChartArgs =
3536
| ({ type: "line"; data: { series: LineSeries[] } } & ChartCommonOpts);
3637

3738
export interface RenderChartResult {
39+
schema_version: 1;
3840
chart_id: string;
3941
type: "pie" | "bar" | "line";
42+
artifact_kind: "chart_spec";
4043
spec_path: string;
44+
/**
45+
* Kept for backwards-compatible metadata shape. Empty because render_chart
46+
* persists a JSON chart spec; the portal renders SVG client-side.
47+
*/
4148
svg_path: string;
4249
bytes: number;
4350
embed_instructions: string;

portal-web/src/components/chat/ChartRenderer.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe("tryParseChartSpec", () => {
77
'{"type":"pie","data":{"slices":[{"label":"a","value":1},{"label":"b","value":2}]}}',
88
)
99
expect(spec?.type).toBe("pie")
10+
expect(spec?.schema_version).toBe(1)
1011
expect(spec).toMatchObject({ data: { slices: [{ label: "a", value: 1 }, { label: "b", value: 2 }] } })
1112
})
1213

@@ -30,6 +31,15 @@ describe("tryParseChartSpec", () => {
3031
expect(tryParseChartSpec('{"type":"pie"}')).toBeNull()
3132
})
3233

34+
it("accepts missing schema_version as v1 but rejects unknown versions", () => {
35+
expect(
36+
tryParseChartSpec('{"schema_version":1,"type":"pie","data":{"slices":[{"label":"a","value":1}]}}')?.schema_version,
37+
).toBe(1)
38+
expect(
39+
tryParseChartSpec('{"schema_version":2,"type":"pie","data":{"slices":[{"label":"a","value":1}]}}'),
40+
).toBeNull()
41+
})
42+
3343
// The chart spec round-trips through the LLM as text; the model sometimes
3444
// double-escapes non-ASCII, leaving literal \uXXXX sequences after JSON.parse.
3545
it("decodes stray \\uXXXX escapes in title and labels", () => {

portal-web/src/components/chat/Markdown.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ function permissiveUrlTransform(uri: string): string {
8383
return ""
8484
}
8585

86+
function hasLanguageClass(className: string | undefined, language: string): boolean {
87+
return className?.split(/\s+/).includes(`language-${language}`) ?? false
88+
}
89+
8690
function ChartLoading() {
8791
return (
8892
<div
@@ -155,7 +159,7 @@ export function Markdown({ children }: MarkdownProps) {
155159
? rawChildren.join("")
156160
: String(rawChildren ?? "")
157161

158-
if (className === "language-chart") {
162+
if (hasLanguageClass(className, "chart")) {
159163
const trimmed = text.trim()
160164
const spec = tryParseChartSpec(trimmed)
161165
if (spec) return <ChartRenderer spec={spec} />

portal-web/src/components/chat/chart-utils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export type BarSeries = { name: string; values: number[] }
1313
export type LinePoint = { x: number | string; y: number }
1414
export type LineSeries = { name: string; points: LinePoint[] }
1515

16+
export const CHART_SPEC_VERSION = 1
17+
1618
export interface CommonOpts {
19+
schema_version?: typeof CHART_SPEC_VERSION
1720
title?: string
1821
x_label?: string
1922
y_label?: string
@@ -389,8 +392,10 @@ function toCleanString(v: unknown): string {
389392
return decodeStrayUnicodeEscapes(String(v))
390393
}
391394

392-
function pickCommonOpts(obj: Record<string, unknown>): CommonOpts {
393-
const out: CommonOpts = {}
395+
function pickCommonOpts(obj: Record<string, unknown>): CommonOpts | null {
396+
const rawVersion = obj.schema_version
397+
if (rawVersion !== undefined && rawVersion !== CHART_SPEC_VERSION) return null
398+
const out: CommonOpts = { schema_version: CHART_SPEC_VERSION }
394399
for (const k of ["title", "x_label", "y_label"] as const) {
395400
if (typeof obj[k] === "string") out[k] = decodeStrayUnicodeEscapes(obj[k] as string)
396401
}
@@ -426,6 +431,7 @@ export function tryParseChartSpec(raw: string): ChartSpec | null {
426431
const data = obj.data as Record<string, unknown> | undefined
427432
if (!data || typeof data !== "object") return null
428433
const common = pickCommonOpts(obj)
434+
if (!common) return null
429435

430436
if (obj.type === "pie") {
431437
const rawSlices = data.slices

0 commit comments

Comments
 (0)