@blueprint-chart/mcp 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +27 -14
  2. package/dist/cli.js +15 -2
  3. package/dist/dsl/chartTypes.d.ts +16 -0
  4. package/dist/dsl/chartTypes.js +37 -0
  5. package/dist/dsl/chartTypes.test.d.ts +1 -0
  6. package/dist/dsl/chartTypes.test.js +32 -0
  7. package/dist/dsl/dataKey.d.ts +25 -0
  8. package/dist/dsl/dataKey.js +42 -0
  9. package/dist/dsl/dataKey.test.d.ts +1 -0
  10. package/dist/dsl/dataKey.test.js +35 -0
  11. package/dist/dsl/suggest.d.ts +1 -0
  12. package/dist/dsl/suggest.js +47 -0
  13. package/dist/dsl/suggest.test.d.ts +1 -0
  14. package/dist/dsl/suggest.test.js +20 -0
  15. package/dist/dsl/universalProperties.d.ts +30 -0
  16. package/dist/dsl/universalProperties.js +52 -0
  17. package/dist/dsl/universalProperties.test.d.ts +1 -0
  18. package/dist/dsl/universalProperties.test.js +26 -0
  19. package/dist/dsl/validate.d.ts +10 -0
  20. package/dist/dsl/validate.js +68 -0
  21. package/dist/dsl/validate.test.d.ts +1 -0
  22. package/dist/dsl/validate.test.js +73 -0
  23. package/dist/errors.d.ts +20 -1
  24. package/dist/errors.js +1 -0
  25. package/dist/errors.test.js +21 -0
  26. package/dist/lib/zodToJsonSchema.d.ts +10 -5
  27. package/dist/lib/zodToJsonSchema.js +14 -6
  28. package/dist/links/buildUrls.d.ts +14 -0
  29. package/dist/links/buildUrls.js +20 -0
  30. package/dist/links/buildUrls.test.d.ts +1 -0
  31. package/dist/links/buildUrls.test.js +28 -0
  32. package/dist/links/editorConfig.d.ts +4 -0
  33. package/dist/links/editorConfig.js +15 -0
  34. package/dist/links/editorConfig.test.d.ts +1 -0
  35. package/dist/links/editorConfig.test.js +28 -0
  36. package/dist/links/encode.d.ts +11 -0
  37. package/dist/links/encode.js +19 -0
  38. package/dist/links/encode.test.d.ts +1 -0
  39. package/dist/links/encode.test.js +37 -0
  40. package/dist/prompts/authorChart.js +23 -18
  41. package/dist/prompts/authorChart.test.js +6 -0
  42. package/dist/render/diagnose.d.ts +19 -0
  43. package/dist/render/diagnose.js +100 -0
  44. package/dist/render/diagnose.test.d.ts +1 -0
  45. package/dist/render/diagnose.test.js +53 -0
  46. package/dist/render/frame.d.ts +10 -0
  47. package/dist/render/frame.js +10 -0
  48. package/dist/render/frame.test.d.ts +1 -0
  49. package/dist/render/frame.test.js +12 -0
  50. package/dist/render/jsdomEnv.d.ts +2 -1
  51. package/dist/render/jsdomEnv.js +14 -1
  52. package/dist/render/jsdomEnv.test.js +36 -2
  53. package/dist/render/renderSceneState.d.ts +5 -1
  54. package/dist/render/renderSceneState.js +4 -3
  55. package/dist/render/renderSceneState.test.js +13 -7
  56. package/dist/render/validatePipeline.d.ts +23 -0
  57. package/dist/render/validatePipeline.js +41 -0
  58. package/dist/render/validatePipeline.test.d.ts +1 -0
  59. package/dist/render/validatePipeline.test.js +34 -0
  60. package/dist/resources/docsReader.d.ts +4 -1
  61. package/dist/resources/docsReader.js +23 -6
  62. package/dist/resources/docsReader.test.js +27 -2
  63. package/dist/resources/index.d.ts +1 -1
  64. package/dist/resources/samples.d.ts +1 -2
  65. package/dist/server.d.ts +9 -0
  66. package/dist/server.js +63 -5
  67. package/dist/server.test.js +101 -4
  68. package/dist/tools/describeChartType.d.ts +33 -0
  69. package/dist/tools/describeChartType.js +119 -0
  70. package/dist/tools/describeChartType.test.d.ts +1 -0
  71. package/dist/tools/describeChartType.test.js +58 -0
  72. package/dist/tools/exportChart.d.ts +17 -0
  73. package/dist/tools/exportChart.js +31 -0
  74. package/dist/tools/exportChart.test.d.ts +1 -0
  75. package/dist/tools/exportChart.test.js +43 -0
  76. package/dist/tools/getExample.d.ts +20 -0
  77. package/dist/tools/getExample.js +55 -0
  78. package/dist/tools/getExample.test.d.ts +1 -0
  79. package/dist/tools/getExample.test.js +40 -0
  80. package/dist/tools/getGrammar.d.ts +17 -0
  81. package/dist/tools/getGrammar.js +38 -0
  82. package/dist/tools/getGrammar.test.d.ts +1 -0
  83. package/dist/tools/getGrammar.test.js +24 -0
  84. package/dist/tools/inspect.d.ts +8 -1
  85. package/dist/tools/inspect.js +31 -7
  86. package/dist/tools/inspect.test.js +35 -13
  87. package/dist/tools/listChartTypes.d.ts +14 -0
  88. package/dist/tools/listChartTypes.js +42 -0
  89. package/dist/tools/listChartTypes.test.d.ts +1 -0
  90. package/dist/tools/listChartTypes.test.js +42 -0
  91. package/dist/tools/render.d.ts +14 -12
  92. package/dist/tools/render.js +96 -28
  93. package/dist/tools/render.test.js +137 -1
  94. package/dist/tools/validate.d.ts +11 -3
  95. package/dist/tools/validate.js +9 -1
  96. package/dist/tools/validate.test.js +17 -12
  97. package/dist/transports/http.d.ts +4 -2
  98. package/dist/transports/http.js +232 -23
  99. package/dist/transports/http.test.js +158 -22
  100. package/package.json +4 -2
  101. package/public/apple-touch-icon.png +0 -0
  102. package/public/favicon.png +0 -0
  103. package/public/favicon.svg +9 -0
@@ -13,14 +13,21 @@ export interface SceneSummary {
13
13
  name?: string;
14
14
  hasTransition: boolean;
15
15
  }
16
+ export interface DataSummary {
17
+ rowCount: number;
18
+ entryCount: number;
19
+ labels: string[];
20
+ seriesNames: string[];
21
+ multiSeries: boolean;
22
+ }
16
23
  export interface InspectOutput {
17
24
  chartType: string;
18
25
  scenes: SceneSummary[];
26
+ data: DataSummary;
19
27
  hasAnnotations: boolean;
20
28
  hasColorizes: boolean;
21
29
  hasHighlights: boolean;
22
30
  hasAreaFills: boolean;
23
31
  seriesCount: number;
24
- rowCount: number;
25
32
  }
26
33
  export declare function inspectDsl(input: InspectInput): ToolResult<InspectOutput>;
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { astToDefinition } from '@blueprint-chart/lib';
3
3
  import { parseDsl } from '../parse';
4
4
  import { toolOk } from '../errors';
5
+ import { looksLikeQuotedLabel } from '../dsl/dataKey';
5
6
  export const InspectInputSchema = z.object({
6
7
  source: z.string(),
7
8
  });
@@ -13,25 +14,48 @@ function summarizeScenes(ast) {
13
14
  return scenes.map((scene, i) => ({
14
15
  index: i,
15
16
  name: scene.name ?? undefined,
16
- // SceneNode has no explicit `transition` field; transforms power animated
17
- // transitions in the lib, so non-empty transforms imply a transition.
18
17
  hasTransition: (scene.transforms?.length ?? 0) > 0,
19
18
  }));
20
19
  }
20
+ function summarizeData(ast) {
21
+ const entries = ast.data?.entries ?? [];
22
+ const seriesEntry = entries.find(e => e.key === '_series');
23
+ const seriesNames = seriesEntry
24
+ ? String(seriesEntry.value).split(',').map(s => s.trim().replace(/^"|"$/g, ''))
25
+ : [];
26
+ const rowEntries = entries.filter(looksLikeQuotedLabel);
27
+ return {
28
+ rowCount: rowEntries.length,
29
+ entryCount: entries.length,
30
+ labels: rowEntries.map(e => e.key.replace(/^"|"$/g, '')),
31
+ seriesNames,
32
+ multiSeries: seriesNames.length > 0,
33
+ };
34
+ }
35
+ function countHighlights(ast) {
36
+ // Grammar: `highlight "X" { … }` parses as ColorizeNode with fromHighlight:true,
37
+ // `highlight "X"` (no braces) parses as HighlightNode. Count both.
38
+ const fromHighlightColorizes = (ast.colorizes ?? []).filter((c) => c.fromHighlight === true).length;
39
+ return (ast.highlights?.length ?? 0) + fromHighlightColorizes;
40
+ }
41
+ function countNonHighlightColorizes(ast) {
42
+ return (ast.colorizes ?? []).filter((c) => c.fromHighlight !== true).length;
43
+ }
21
44
  export function inspectDsl(input) {
22
45
  const parsed = parseDsl(input.source);
23
46
  if (!parsed.ok) {
24
47
  return parsed;
25
48
  }
26
- const def = astToDefinition(parsed.data.ast);
49
+ const ast = parsed.data.ast;
50
+ const def = astToDefinition(ast);
27
51
  return toolOk({
28
52
  chartType: def.chartType,
29
- scenes: summarizeScenes(parsed.data.ast),
53
+ scenes: summarizeScenes(ast),
54
+ data: summarizeData(ast),
30
55
  hasAnnotations: (def.annotations?.length ?? 0) > 0,
31
- hasColorizes: (def.colorizes?.length ?? 0) > 0,
32
- hasHighlights: (def.highlights?.length ?? 0) > 0,
56
+ hasColorizes: countNonHighlightColorizes(ast) > 0,
57
+ hasHighlights: countHighlights(ast) > 0,
33
58
  hasAreaFills: (def.areaFills?.length ?? 0) > 0,
34
59
  seriesCount: def.data.series?.length ?? 0,
35
- rowCount: def.data.labels?.length ?? 0,
36
60
  });
37
61
  }
@@ -2,26 +2,48 @@ import { describe, expect, it } from 'vitest';
2
2
  import { samples } from '@blueprint-chart/lib';
3
3
  import { inspectDsl } from './inspect';
4
4
  describe('inspect_dsl', () => {
5
- it('returns chartType + scene summary for a sample', () => {
6
- const sample = samples[0];
7
- const r = inspectDsl({ source: sample.dsl });
8
- expect(r.ok).toBe(true);
9
- if (r.ok) {
10
- expect(r.data.chartType).toBe(sample.chartType);
11
- expect(Array.isArray(r.data.scenes)).toBe(true);
12
- expect(r.data.seriesCount).toBeGreaterThanOrEqual(0);
13
- expect(r.data.rowCount).toBeGreaterThanOrEqual(0);
14
- }
15
- });
16
- it('returns at least one scene for every shipped sample', () => {
5
+ it('returns chartType + data summary for every sample', () => {
17
6
  for (const s of samples) {
18
7
  const r = inspectDsl({ source: s.dsl });
19
8
  expect(r.ok, `sample ${s.id}`).toBe(true);
20
9
  if (r.ok) {
21
- expect(r.data.scenes.length).toBeGreaterThanOrEqual(1);
10
+ expect(r.data.chartType).toBe(s.chartType);
11
+ expect(r.data.data.rowCount).toBeGreaterThan(0);
12
+ expect(Array.isArray(r.data.data.labels)).toBe(true);
13
+ expect(r.data.data.labels.length).toBe(r.data.data.rowCount);
22
14
  }
23
15
  }
24
16
  });
17
+ it('rowCount counts parsed rows only, not arbitrary AST entries', () => {
18
+ // Unquoted-identifier keys are now an E_UNKNOWN_DATA_KEY situation; the
19
+ // inspector should still parse but rowCount reflects only quoted labels.
20
+ const r = inspectDsl({
21
+ source: 'chart bar-vertical { data { a = "foo" b = "bar" "E" = 1 } }',
22
+ });
23
+ expect(r.ok).toBe(true);
24
+ if (r.ok) {
25
+ expect(r.data.data.rowCount).toBe(1); // only "E" counts
26
+ expect(r.data.data.entryCount).toBe(3); // a, b, "E"
27
+ }
28
+ });
29
+ it('hasHighlights is true for braced highlight syntax', () => {
30
+ const r = inspectDsl({
31
+ source: 'chart bar-vertical { data { "E" = 1 } highlight "E" { color = "red" } }',
32
+ });
33
+ expect(r.ok).toBe(true);
34
+ if (r.ok) {
35
+ expect(r.data.hasHighlights).toBe(true);
36
+ }
37
+ });
38
+ it('hasHighlights is true for bare highlight syntax', () => {
39
+ const r = inspectDsl({
40
+ source: 'chart bar-vertical { data { "E" = 1 } highlight "E" }',
41
+ });
42
+ expect(r.ok).toBe(true);
43
+ if (r.ok) {
44
+ expect(r.data.hasHighlights).toBe(true);
45
+ }
46
+ });
25
47
  it('forwards parse errors', () => {
26
48
  const r = inspectDsl({ source: '@@@ not valid' });
27
49
  expect(r.ok).toBe(false);
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { type ToolResult } from '../errors';
3
+ export declare const ListChartTypesInputSchema: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
4
+ export type ListChartTypesInput = z.infer<typeof ListChartTypesInputSchema>;
5
+ export interface ChartTypeListEntry {
6
+ name: string;
7
+ aliases: string[];
8
+ summary: string;
9
+ docsUrl?: string;
10
+ }
11
+ export interface ListChartTypesOutput {
12
+ chartTypes: ChartTypeListEntry[];
13
+ }
14
+ export declare function listChartTypes(): ToolResult<ListChartTypesOutput>;
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import { getDoc, listDocs } from '@blueprint-chart/docs';
3
+ import { aliasesFor, listCanonicalChartTypes } from '../dsl/chartTypes';
4
+ import { publicDocUrl } from '../resources/docsReader';
5
+ import { toolOk } from '../errors';
6
+ export const ListChartTypesInputSchema = z.object({}).strict();
7
+ function summaryFor(name) {
8
+ // The chart-type docs follow a consistent template: an H1 followed by a
9
+ // blockquote subtitle. Example (`bar-horizontal.md`):
10
+ // # Horizontal bar chart
11
+ //
12
+ // > Single-series horizontal bar chart for long category labels and ranked bars.
13
+ // If the doc is missing or the template doesn't match, return ''.
14
+ const entries = listDocs('charts');
15
+ const entry = entries.find(e => e.slug === name);
16
+ if (!entry) {
17
+ return '';
18
+ }
19
+ try {
20
+ const { content } = getDoc('charts', name);
21
+ const match = content.match(/^>\s*(.+)$/m);
22
+ return match?.[1]?.trim() ?? '';
23
+ }
24
+ catch {
25
+ return '';
26
+ }
27
+ }
28
+ export function listChartTypes() {
29
+ const chartTypes = listCanonicalChartTypes().map((name) => {
30
+ const entry = {
31
+ name,
32
+ aliases: aliasesFor(name),
33
+ summary: summaryFor(name),
34
+ };
35
+ const docsUrl = publicDocUrl('charts', name);
36
+ if (docsUrl) {
37
+ entry.docsUrl = docsUrl;
38
+ }
39
+ return entry;
40
+ });
41
+ return toolOk({ chartTypes });
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { listChartTypes } from './listChartTypes';
3
+ describe('list_chart_types', () => {
4
+ afterEach(() => {
5
+ delete process.env.BLUEPRINT_CHART_DOCS_URL;
6
+ });
7
+ it('returns canonical chart types with aliases and a summary', () => {
8
+ const r = listChartTypes();
9
+ expect(r.ok).toBe(true);
10
+ if (r.ok) {
11
+ const types = r.data.chartTypes;
12
+ expect(types.length).toBeGreaterThan(5);
13
+ const horiz = types.find(t => t.name === 'bar-horizontal');
14
+ expect(horiz).toBeDefined();
15
+ expect(horiz.aliases).toContain('horizontal-bar');
16
+ expect(horiz.summary.length).toBeGreaterThan(0);
17
+ }
18
+ });
19
+ it('does not include alias names as canonical entries', () => {
20
+ const r = listChartTypes();
21
+ expect(r.ok).toBe(true);
22
+ if (r.ok) {
23
+ expect(r.data.chartTypes.find(t => t.name === 'horizontal-bar')).toBeUndefined();
24
+ }
25
+ });
26
+ it('omits docsUrl when docs base is unset', () => {
27
+ const result = listChartTypes();
28
+ expect(result.ok).toBe(true);
29
+ if (result.ok) {
30
+ expect(result.data.chartTypes[0]?.docsUrl).toBeUndefined();
31
+ }
32
+ });
33
+ it('includes docsUrl per entry when docs base is set', () => {
34
+ process.env.BLUEPRINT_CHART_DOCS_URL = 'https://docs.blueprintchart.com';
35
+ const result = listChartTypes();
36
+ expect(result.ok).toBe(true);
37
+ if (result.ok) {
38
+ const entry = result.data.chartTypes[0];
39
+ expect(entry?.docsUrl).toBe(`https://docs.blueprintchart.com/charts/${entry?.name}`);
40
+ }
41
+ });
42
+ });
@@ -1,35 +1,37 @@
1
1
  import { z } from 'zod';
2
+ import { type FrameMetadata } from '../render/frame';
2
3
  import { type ToolResult } from '../errors';
3
4
  export declare const RenderInputSchema: z.ZodObject<{
4
5
  source: z.ZodString;
5
- format: z.ZodDefault<z.ZodEnum<["svg", "png"]>>;
6
+ format: z.ZodDefault<z.ZodEnum<["svg", "png", "html"]>>;
6
7
  scene: z.ZodOptional<z.ZodNumber>;
7
8
  width: z.ZodDefault<z.ZodNumber>;
8
9
  height: z.ZodDefault<z.ZodNumber>;
10
+ /** Optional file path (absolute, or relative to MCP server CWD). When provided, the primary output (PNG bytes / SVG / HTML) is written to that path and the inline content is omitted from the response. Requires MCP_ALLOW_FS_WRITE=1. */
11
+ save: z.ZodOptional<z.ZodString>;
9
12
  }, "strip", z.ZodTypeAny, {
10
13
  source: string;
11
14
  width: number;
12
15
  height: number;
13
- format: "svg" | "png";
16
+ format: "html" | "svg" | "png";
14
17
  scene?: number | undefined;
18
+ save?: string | undefined;
15
19
  }, {
16
20
  source: string;
17
21
  width?: number | undefined;
18
22
  height?: number | undefined;
19
- format?: "svg" | "png" | undefined;
20
23
  scene?: number | undefined;
24
+ format?: "html" | "svg" | "png" | undefined;
25
+ save?: string | undefined;
21
26
  }>;
22
27
  export type RenderInput = z.infer<typeof RenderInputSchema>;
23
28
  export interface RenderOutput {
24
- svg: string;
29
+ svg?: string;
25
30
  png?: string;
26
- mimeType: 'image/svg+xml' | 'image/png';
31
+ html?: string;
32
+ frame: FrameMetadata;
33
+ mimeType: 'image/svg+xml' | 'image/png' | 'text/html';
34
+ /** When `save` was used, the resolved absolute path the output was written to. Inline content fields (svg/png/html) are omitted. */
35
+ savedTo?: string;
27
36
  }
28
- /**
29
- * Composes `parseDsl`, `renderSceneState`, and `rasterizeToPng` into the
30
- * `render` MCP tool. Always returns SVG; when `format=png`, also includes a
31
- * base64-encoded PNG. If rasterisation fails we surface `E_RENDER` — the SVG
32
- * is discarded in that branch to keep the union shape (ToolResult is either
33
- * ok-with-data or err-with-errors, no partial-success carrier).
34
- */
35
37
  export declare function renderTool(input: unknown): Promise<ToolResult<RenderOutput>>;
@@ -1,63 +1,131 @@
1
1
  import { z } from 'zod';
2
- import { parseDsl } from '../parse';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { resolve as resolvePath } from 'node:path';
4
+ import { extractFrameMetadata } from '../render/frame';
3
5
  import { renderSceneState } from '../render/renderSceneState';
4
6
  import { rasterizeToPng } from '../render/rasterize';
7
+ import { validatePipeline } from '../render/validatePipeline';
5
8
  import { ErrorCode, toolError, toolOk } from '../errors';
6
9
  export const RenderInputSchema = z.object({
7
10
  source: z.string(),
8
- format: z.enum(['svg', 'png']).default('svg'),
11
+ format: z.enum(['svg', 'png', 'html']).default('svg'),
9
12
  scene: z.number().int().nonnegative().optional(),
10
13
  width: z.number().int().positive().default(800),
11
14
  height: z.number().int().positive().default(500),
15
+ /** Optional file path (absolute, or relative to MCP server CWD). When provided, the primary output (PNG bytes / SVG / HTML) is written to that path and the inline content is omitted from the response. Requires MCP_ALLOW_FS_WRITE=1. */
16
+ save: z.string().optional(),
12
17
  });
13
- /**
14
- * `renderSceneState` returns the SVG fragment as produced by jsdom's
15
- * `outerHTML`, which omits the default SVG namespace. resvg's strict parser
16
- * rejects that as "the document does not have a root node". Inject the
17
- * namespace on the way to the rasterizer so the PNG path succeeds.
18
- */
18
+ function isFsWriteEnabled() {
19
+ const v = process.env.MCP_ALLOW_FS_WRITE;
20
+ return v === '1' || v === 'true' || v === 'yes';
21
+ }
22
+ async function trySave(absPath, body) {
23
+ try {
24
+ await writeFile(absPath, body);
25
+ return { savedTo: absPath };
26
+ }
27
+ catch (err) {
28
+ return { error: err instanceof Error ? err.message : String(err) };
29
+ }
30
+ }
19
31
  function ensureSvgNamespace(svg) {
20
32
  if (svg.includes('xmlns="http://www.w3.org/2000/svg"')) {
21
33
  return svg;
22
34
  }
23
35
  return svg.replace(/^<svg(?=\s|>)/, '<svg xmlns="http://www.w3.org/2000/svg"');
24
36
  }
25
- /**
26
- * Composes `parseDsl`, `renderSceneState`, and `rasterizeToPng` into the
27
- * `render` MCP tool. Always returns SVG; when `format=png`, also includes a
28
- * base64-encoded PNG. If rasterisation fails we surface `E_RENDER` — the SVG
29
- * is discarded in that branch to keep the union shape (ToolResult is either
30
- * ok-with-data or err-with-errors, no partial-success carrier).
31
- */
32
37
  export async function renderTool(input) {
33
38
  const parsed = RenderInputSchema.safeParse(input);
34
39
  if (!parsed.success) {
35
40
  return toolError(ErrorCode.E_INPUT, parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })));
36
41
  }
37
- const { source, format, scene, width, height } = parsed.data;
38
- const parseResult = parseDsl(source);
39
- if (!parseResult.ok) {
40
- return parseResult;
42
+ const { source, format, scene, width, height, save } = parsed.data;
43
+ if (save !== undefined && !isFsWriteEnabled()) {
44
+ return toolError(ErrorCode.E_INPUT, [{
45
+ code: 'E_FS_WRITE_DISABLED',
46
+ path: 'save',
47
+ message: 'File saving is disabled. Set MCP_ALLOW_FS_WRITE=1 to enable. This is typically only safe in local stdio mode.',
48
+ }]);
41
49
  }
50
+ const validated = validatePipeline(source, { sceneIndex: scene });
51
+ if (!validated.ok) {
52
+ return validated.error;
53
+ }
54
+ const frame = extractFrameMetadata(validated.ast);
55
+ // Layer 3: actual render.
42
56
  let svg;
57
+ let html;
43
58
  try {
44
- svg = renderSceneState(source, { sceneIndex: scene, width, height });
59
+ const result = renderSceneState(source, { sceneIndex: scene, width, height });
60
+ svg = result.svg;
61
+ html = result.html;
45
62
  }
46
63
  catch (err) {
47
- return toolError(ErrorCode.E_RENDER, [
48
- { path: 'render', message: err instanceof Error ? err.message : String(err) },
49
- ]);
64
+ return toolError(ErrorCode.E_RENDER, [{
65
+ code: 'E_RENDER_UNKNOWN',
66
+ path: 'render',
67
+ message: err instanceof Error ? err.message : String(err),
68
+ }]);
50
69
  }
51
70
  if (format === 'svg') {
52
- return toolOk({ svg, mimeType: 'image/svg+xml' });
71
+ if (save !== undefined) {
72
+ const absPath = resolvePath(save);
73
+ const result = await trySave(absPath, svg);
74
+ if ('error' in result) {
75
+ return toolError(ErrorCode.E_RENDER, [{
76
+ code: 'E_SAVE_FAILED',
77
+ path: 'save',
78
+ message: result.error,
79
+ }]);
80
+ }
81
+ return toolOk({ frame, mimeType: 'image/svg+xml', savedTo: result.savedTo });
82
+ }
83
+ return toolOk({ svg, frame, mimeType: 'image/svg+xml' });
84
+ }
85
+ if (format === 'html') {
86
+ if (!html) {
87
+ return toolError(ErrorCode.E_RENDER, [{
88
+ code: 'E_FRAME_UNAVAILABLE',
89
+ path: 'render',
90
+ message: 'HTML frame was not produced by the renderer',
91
+ }]);
92
+ }
93
+ if (save !== undefined) {
94
+ const absPath = resolvePath(save);
95
+ const result = await trySave(absPath, html);
96
+ if ('error' in result) {
97
+ return toolError(ErrorCode.E_RENDER, [{
98
+ code: 'E_SAVE_FAILED',
99
+ path: 'save',
100
+ message: result.error,
101
+ }]);
102
+ }
103
+ return toolOk({ frame, mimeType: 'text/html', savedTo: result.savedTo });
104
+ }
105
+ return toolOk({ svg, html, frame, mimeType: 'text/html' });
53
106
  }
107
+ // format === 'png'
54
108
  try {
55
109
  const png = await rasterizeToPng(ensureSvgNamespace(svg), { width });
56
- return toolOk({ svg, png: png.toString('base64'), mimeType: 'image/png' });
110
+ if (save !== undefined) {
111
+ const absPath = resolvePath(save);
112
+ const result = await trySave(absPath, png);
113
+ if ('error' in result) {
114
+ return toolError(ErrorCode.E_RENDER, [{
115
+ code: 'E_SAVE_FAILED',
116
+ path: 'save',
117
+ message: result.error,
118
+ }]);
119
+ }
120
+ return toolOk({ frame, mimeType: 'image/png', savedTo: result.savedTo });
121
+ }
122
+ return toolOk({ svg, png: png.toString('base64'), frame, mimeType: 'image/png' });
57
123
  }
58
124
  catch (err) {
59
- return toolError(ErrorCode.E_RENDER, [
60
- { path: 'rasterize', message: err instanceof Error ? err.message : String(err) },
61
- ]);
125
+ return toolError(ErrorCode.E_RENDER, [{
126
+ code: 'E_RASTERIZE',
127
+ path: 'rasterize',
128
+ message: err instanceof Error ? err.message : String(err),
129
+ }]);
62
130
  }
63
131
  }
@@ -2,13 +2,15 @@ import { describe, expect, it } from 'vitest';
2
2
  import { samples } from '@blueprint-chart/lib';
3
3
  import { renderTool } from './render';
4
4
  describe('render', () => {
5
- it('returns SVG by default', async () => {
5
+ it('returns pure SVG by default', async () => {
6
6
  const r = await renderTool({ source: samples[0].dsl });
7
7
  expect(r.ok).toBe(true);
8
8
  if (r.ok) {
9
9
  expect(r.data.mimeType).toBe('image/svg+xml');
10
10
  expect(r.data.svg).toMatch(/^<svg/);
11
11
  expect(r.data.png).toBeUndefined();
12
+ expect(r.data.html).toBeUndefined();
13
+ expect(r.data.frame).toBeDefined();
12
14
  }
13
15
  });
14
16
  it('returns both SVG and PNG when format=png', async () => {
@@ -19,6 +21,7 @@ describe('render', () => {
19
21
  expect(r.data.svg).toMatch(/^<svg/);
20
22
  expect(r.data.png).toBeTypeOf('string'); // base64
21
23
  expect(r.data.png.length).toBeGreaterThan(100);
24
+ expect(r.data.frame).toBeDefined();
22
25
  }
23
26
  });
24
27
  it('forwards parse errors', async () => {
@@ -37,3 +40,136 @@ describe('render', () => {
37
40
  }
38
41
  });
39
42
  });
43
+ describe('render — frame defaults', () => {
44
+ it('format=svg returns pure SVG without HTML wrapper', async () => {
45
+ const { samples } = await import('@blueprint-chart/lib');
46
+ const sample = samples.find(s => s.id === 'letter-frequency');
47
+ const r = await renderTool({ source: sample.dsl, format: 'svg' });
48
+ expect(r.ok).toBe(true);
49
+ if (r.ok) {
50
+ expect(r.data.svg).toMatch(/^<svg/);
51
+ expect(r.data.svg).not.toContain('<div class="bc-frame"');
52
+ expect(r.data.html).toBeUndefined();
53
+ }
54
+ });
55
+ it('format=svg on letter-frequency returns correct frame.title', async () => {
56
+ const { samples } = await import('@blueprint-chart/lib');
57
+ const sample = samples.find(s => s.id === 'letter-frequency');
58
+ const r = await renderTool({ source: sample.dsl, format: 'svg' });
59
+ expect(r.ok).toBe(true);
60
+ if (r.ok) {
61
+ expect(r.data.frame.title).toBe('E is the most frequent letter in English');
62
+ }
63
+ });
64
+ it('format=html returns html containing bc-frame and svg', async () => {
65
+ const { samples } = await import('@blueprint-chart/lib');
66
+ const sample = samples.find(s => s.id === 'letter-frequency');
67
+ const r = await renderTool({ source: sample.dsl, format: 'html' });
68
+ expect(r.ok).toBe(true);
69
+ if (r.ok) {
70
+ expect(r.data.mimeType).toBe('text/html');
71
+ expect(r.data.html).toContain('<div class="bc-frame"');
72
+ expect(r.data.html).toContain('<svg');
73
+ expect(r.data.svg).toMatch(/^<svg/);
74
+ expect(r.data.frame).toBeDefined();
75
+ }
76
+ });
77
+ });
78
+ describe('render — save option', () => {
79
+ it('errors when MCP_ALLOW_FS_WRITE is unset', async () => {
80
+ delete process.env.MCP_ALLOW_FS_WRITE;
81
+ const r = await renderTool({
82
+ source: 'chart bar-vertical { data { "E" = 1 } }',
83
+ format: 'png',
84
+ save: '/tmp/test.png',
85
+ });
86
+ expect(r.ok).toBe(false);
87
+ if (!r.ok) {
88
+ expect(r.errors[0].code).toBe('E_FS_WRITE_DISABLED');
89
+ }
90
+ });
91
+ it('writes the PNG when MCP_ALLOW_FS_WRITE=1', async () => {
92
+ process.env.MCP_ALLOW_FS_WRITE = '1';
93
+ const { samples } = await import('@blueprint-chart/lib');
94
+ const sample = samples.find(s => s.id === 'letter-frequency');
95
+ const tmp = `/tmp/mcp-render-test-${Date.now()}.png`;
96
+ try {
97
+ const r = await renderTool({ source: sample.dsl, format: 'png', save: tmp });
98
+ expect(r.ok).toBe(true);
99
+ if (r.ok) {
100
+ expect(r.data.savedTo).toBe(tmp);
101
+ expect(r.data.png).toBeUndefined(); // inline payload omitted
102
+ const { statSync } = await import('node:fs');
103
+ expect(statSync(tmp).size).toBeGreaterThan(1000); // real PNG, not stub
104
+ }
105
+ }
106
+ finally {
107
+ const { unlinkSync, existsSync } = await import('node:fs');
108
+ if (existsSync(tmp)) {
109
+ unlinkSync(tmp);
110
+ }
111
+ delete process.env.MCP_ALLOW_FS_WRITE;
112
+ }
113
+ });
114
+ it('writes the SVG when format=svg and save provided', async () => {
115
+ process.env.MCP_ALLOW_FS_WRITE = '1';
116
+ const tmp = `/tmp/mcp-render-test-${Date.now()}.svg`;
117
+ try {
118
+ const r = await renderTool({
119
+ source: 'chart bar-vertical { title = "x" data { "E" = 1 } }',
120
+ format: 'svg',
121
+ save: tmp,
122
+ });
123
+ expect(r.ok).toBe(true);
124
+ if (r.ok) {
125
+ expect(r.data.savedTo).toBe(tmp);
126
+ expect(r.data.svg).toBeUndefined();
127
+ }
128
+ }
129
+ finally {
130
+ const { unlinkSync, existsSync } = await import('node:fs');
131
+ if (existsSync(tmp)) {
132
+ unlinkSync(tmp);
133
+ }
134
+ delete process.env.MCP_ALLOW_FS_WRITE;
135
+ }
136
+ });
137
+ });
138
+ describe('render — structured diagnostics', () => {
139
+ it('returns E_SEMANTIC + E_UNKNOWN_CHART_TYPE for chart bar', async () => {
140
+ const r = await renderTool({ source: 'chart bar { data { "E" = 1 } }' });
141
+ expect(r.ok).toBe(false);
142
+ if (!r.ok) {
143
+ expect(r.code).toBe('E_SEMANTIC');
144
+ expect(r.errors[0].code).toBe('E_UNKNOWN_CHART_TYPE');
145
+ expect(r.errors[0].suggestion).toMatch(/^bar-/);
146
+ }
147
+ });
148
+ it('returns E_SEMANTIC + E_EMPTY_DATA when data is empty', async () => {
149
+ const r = await renderTool({ source: 'chart bar-vertical { title = "x" }' });
150
+ expect(r.ok).toBe(false);
151
+ if (!r.ok) {
152
+ expect(r.code).toBe('E_SEMANTIC');
153
+ expect(r.errors[0].code).toBe('E_EMPTY_DATA');
154
+ }
155
+ });
156
+ it('never returns the generic "produced no SVG output" message', async () => {
157
+ const r = await renderTool({ source: 'chart bar { data { "E" = 1 } }' });
158
+ expect(r.ok).toBe(false);
159
+ if (!r.ok) {
160
+ for (const e of r.errors) {
161
+ expect(e.message).not.toContain('produced no SVG output');
162
+ }
163
+ }
164
+ });
165
+ it('renders every sample to SVG and provides frame metadata', async () => {
166
+ const { samples } = await import('@blueprint-chart/lib');
167
+ for (const s of samples) {
168
+ const r = await renderTool({ source: s.dsl, format: 'svg' });
169
+ expect(r.ok, `sample ${s.id}`).toBe(true);
170
+ if (r.ok) {
171
+ expect(r.data.frame, `sample ${s.id} frame`).toBeDefined();
172
+ }
173
+ }
174
+ });
175
+ });
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { type ValidationIssue } from '../dsl/validate';
2
3
  import { type ToolResult } from '../errors';
3
4
  export declare const ValidateInputSchema: z.ZodObject<{
4
5
  source: z.ZodString;
@@ -8,6 +9,13 @@ export declare const ValidateInputSchema: z.ZodObject<{
8
9
  source: string;
9
10
  }>;
10
11
  export type ValidateInput = z.infer<typeof ValidateInputSchema>;
11
- export declare function validateDsl(input: ValidateInput): ToolResult<{
12
- valid: true;
13
- }>;
12
+ export interface ValidateOutput {
13
+ valid: boolean;
14
+ errors: ValidationIssue[];
15
+ /**
16
+ * Reserved for non-fatal advisories. Currently always empty; future versions
17
+ * may populate this without breaking existing clients.
18
+ */
19
+ warnings: ValidationIssue[];
20
+ }
21
+ export declare function validateDsl(input: ValidateInput): ToolResult<ValidateOutput>;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { parseDsl } from '../parse';
3
+ import { validateAst } from '../dsl/validate';
3
4
  import { toolOk } from '../errors';
4
5
  export const ValidateInputSchema = z.object({
5
6
  source: z.string(),
@@ -9,5 +10,12 @@ export function validateDsl(input) {
9
10
  if (!parsed.ok) {
10
11
  return parsed;
11
12
  }
12
- return toolOk({ valid: true });
13
+ const issues = validateAst(parsed.data.ast);
14
+ return toolOk({
15
+ valid: issues.length === 0,
16
+ errors: issues,
17
+ // `warnings` is reserved for non-fatal advisories. Currently always empty;
18
+ // future versions may populate this without breaking existing clients.
19
+ warnings: [],
20
+ });
13
21
  }