@blueprint-chart/mcp 0.1.1 → 0.1.4

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 (131) hide show
  1. package/README.md +31 -15
  2. package/dist/cli.js +15 -2
  3. package/dist/dsl/capabilityMatrix.d.ts +22 -0
  4. package/dist/dsl/capabilityMatrix.js +37 -0
  5. package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
  6. package/dist/dsl/capabilityMatrix.test.js +49 -0
  7. package/dist/dsl/chartTypes.d.ts +16 -0
  8. package/dist/dsl/chartTypes.js +37 -0
  9. package/dist/dsl/chartTypes.test.d.ts +1 -0
  10. package/dist/dsl/chartTypes.test.js +32 -0
  11. package/dist/dsl/dataKey.d.ts +25 -0
  12. package/dist/dsl/dataKey.js +42 -0
  13. package/dist/dsl/dataKey.test.d.ts +1 -0
  14. package/dist/dsl/dataKey.test.js +35 -0
  15. package/dist/dsl/goalRanking.d.ts +7 -0
  16. package/dist/dsl/goalRanking.js +76 -0
  17. package/dist/dsl/goalRanking.test.d.ts +1 -0
  18. package/dist/dsl/goalRanking.test.js +83 -0
  19. package/dist/dsl/parseErrorHints.d.ts +12 -0
  20. package/dist/dsl/parseErrorHints.js +32 -0
  21. package/dist/dsl/parseErrorHints.test.d.ts +1 -0
  22. package/dist/dsl/parseErrorHints.test.js +26 -0
  23. package/dist/dsl/semanticWarnings.d.ts +7 -0
  24. package/dist/dsl/semanticWarnings.js +66 -0
  25. package/dist/dsl/semanticWarnings.test.d.ts +1 -0
  26. package/dist/dsl/semanticWarnings.test.js +32 -0
  27. package/dist/dsl/suggest.d.ts +1 -0
  28. package/dist/dsl/suggest.js +66 -0
  29. package/dist/dsl/suggest.test.d.ts +1 -0
  30. package/dist/dsl/suggest.test.js +34 -0
  31. package/dist/dsl/universalProperties.d.ts +30 -0
  32. package/dist/dsl/universalProperties.js +52 -0
  33. package/dist/dsl/universalProperties.test.d.ts +1 -0
  34. package/dist/dsl/universalProperties.test.js +26 -0
  35. package/dist/dsl/validate.d.ts +10 -0
  36. package/dist/dsl/validate.js +68 -0
  37. package/dist/dsl/validate.test.d.ts +1 -0
  38. package/dist/dsl/validate.test.js +73 -0
  39. package/dist/errors.d.ts +20 -1
  40. package/dist/errors.js +1 -0
  41. package/dist/errors.test.js +21 -0
  42. package/dist/lib/zodToJsonSchema.d.ts +10 -5
  43. package/dist/lib/zodToJsonSchema.js +14 -6
  44. package/dist/links/buildUrls.d.ts +14 -0
  45. package/dist/links/buildUrls.js +20 -0
  46. package/dist/links/buildUrls.test.d.ts +1 -0
  47. package/dist/links/buildUrls.test.js +28 -0
  48. package/dist/links/editorConfig.d.ts +4 -0
  49. package/dist/links/editorConfig.js +15 -0
  50. package/dist/links/editorConfig.test.d.ts +1 -0
  51. package/dist/links/editorConfig.test.js +28 -0
  52. package/dist/links/encode.d.ts +11 -0
  53. package/dist/links/encode.js +19 -0
  54. package/dist/links/encode.test.d.ts +1 -0
  55. package/dist/links/encode.test.js +37 -0
  56. package/dist/parse.js +14 -6
  57. package/dist/parse.test.js +8 -0
  58. package/dist/prompts/authorChart.js +23 -18
  59. package/dist/prompts/authorChart.test.js +6 -0
  60. package/dist/render/diagnose.d.ts +19 -0
  61. package/dist/render/diagnose.js +100 -0
  62. package/dist/render/diagnose.test.d.ts +1 -0
  63. package/dist/render/diagnose.test.js +53 -0
  64. package/dist/render/frame.d.ts +10 -0
  65. package/dist/render/frame.js +10 -0
  66. package/dist/render/frame.test.d.ts +1 -0
  67. package/dist/render/frame.test.js +12 -0
  68. package/dist/render/jsdomEnv.d.ts +2 -1
  69. package/dist/render/jsdomEnv.js +14 -1
  70. package/dist/render/jsdomEnv.test.js +36 -2
  71. package/dist/render/renderSceneState.d.ts +5 -1
  72. package/dist/render/renderSceneState.js +4 -3
  73. package/dist/render/renderSceneState.test.js +13 -7
  74. package/dist/render/validatePipeline.d.ts +23 -0
  75. package/dist/render/validatePipeline.js +41 -0
  76. package/dist/render/validatePipeline.test.d.ts +1 -0
  77. package/dist/render/validatePipeline.test.js +34 -0
  78. package/dist/resources/docsReader.d.ts +4 -1
  79. package/dist/resources/docsReader.js +23 -6
  80. package/dist/resources/docsReader.test.js +27 -2
  81. package/dist/resources/index.d.ts +1 -1
  82. package/dist/resources/samples.d.ts +1 -2
  83. package/dist/server.d.ts +9 -0
  84. package/dist/server.js +75 -5
  85. package/dist/server.test.js +105 -4
  86. package/dist/tools/describeChartType.d.ts +41 -0
  87. package/dist/tools/describeChartType.js +143 -0
  88. package/dist/tools/describeChartType.test.d.ts +1 -0
  89. package/dist/tools/describeChartType.test.js +78 -0
  90. package/dist/tools/exportChart.d.ts +17 -0
  91. package/dist/tools/exportChart.js +31 -0
  92. package/dist/tools/exportChart.test.d.ts +1 -0
  93. package/dist/tools/exportChart.test.js +43 -0
  94. package/dist/tools/getExample.d.ts +20 -0
  95. package/dist/tools/getExample.js +55 -0
  96. package/dist/tools/getExample.test.d.ts +1 -0
  97. package/dist/tools/getExample.test.js +40 -0
  98. package/dist/tools/getGrammar.d.ts +17 -0
  99. package/dist/tools/getGrammar.js +38 -0
  100. package/dist/tools/getGrammar.test.d.ts +1 -0
  101. package/dist/tools/getGrammar.test.js +35 -0
  102. package/dist/tools/inspect.d.ts +8 -1
  103. package/dist/tools/inspect.js +40 -7
  104. package/dist/tools/inspect.test.js +62 -13
  105. package/dist/tools/listChartTypes.d.ts +14 -0
  106. package/dist/tools/listChartTypes.js +42 -0
  107. package/dist/tools/listChartTypes.test.d.ts +1 -0
  108. package/dist/tools/listChartTypes.test.js +42 -0
  109. package/dist/tools/listPalettes.d.ts +13 -0
  110. package/dist/tools/listPalettes.js +12 -0
  111. package/dist/tools/listPalettes.test.d.ts +1 -0
  112. package/dist/tools/listPalettes.test.js +15 -0
  113. package/dist/tools/recommend.js +3 -1
  114. package/dist/tools/recommend.test.js +40 -0
  115. package/dist/tools/render.d.ts +14 -12
  116. package/dist/tools/render.js +96 -28
  117. package/dist/tools/render.test.js +137 -1
  118. package/dist/tools/searchExamples.d.ts +28 -0
  119. package/dist/tools/searchExamples.js +54 -0
  120. package/dist/tools/searchExamples.test.d.ts +1 -0
  121. package/dist/tools/searchExamples.test.js +32 -0
  122. package/dist/tools/validate.d.ts +9 -3
  123. package/dist/tools/validate.js +11 -1
  124. package/dist/tools/validate.test.js +33 -11
  125. package/dist/transports/http.d.ts +4 -2
  126. package/dist/transports/http.js +232 -23
  127. package/dist/transports/http.test.js +158 -22
  128. package/package.json +5 -3
  129. package/public/apple-touch-icon.png +0 -0
  130. package/public/favicon.png +0 -0
  131. package/public/favicon.svg +9 -0
@@ -2,28 +2,77 @@ 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);
28
50
  });
29
51
  });
52
+ describe('inspect_dsl fixes', () => {
53
+ it('lists ALL series names from the _series header', () => {
54
+ const src = 'chart bar-multi {\n data {\n _series = "Hardware","Software","Services"\n "Q1" = 1,2,3\n }\n}';
55
+ const r = inspectDsl({ source: src });
56
+ expect(r.ok).toBe(true);
57
+ if (r.ok) {
58
+ expect(r.data.data.seriesNames).toEqual(['Hardware', 'Software', 'Services']);
59
+ expect(r.data.data.multiSeries).toBe(true);
60
+ }
61
+ });
62
+ it('reports hasHighlights:true when a highlight lives inside a scene', () => {
63
+ const src = 'chart area-stacked {\n data {\n _series = "A","B"\n "2000" = 1,2\n }\n scene "S1" {\n highlight "A"\n }\n}';
64
+ const r = inspectDsl({ source: src });
65
+ expect(r.ok).toBe(true);
66
+ if (r.ok) {
67
+ expect(r.data.hasHighlights).toBe(true);
68
+ }
69
+ });
70
+ it('reports hasColorizes:true when a colorize lives inside a scene', () => {
71
+ const src = 'chart area-stacked {\n data {\n _series = "A","B"\n "2000" = 1,2\n }\n scene "S1" {\n colorize "A" { color = "#f00" }\n }\n}';
72
+ const r = inspectDsl({ source: src });
73
+ expect(r.ok).toBe(true);
74
+ if (r.ok) {
75
+ expect(r.data.hasColorizes).toBe(true);
76
+ }
77
+ });
78
+ });
@@ -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
+ });
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ import { type ToolResult } from '../errors';
3
+ export declare const ListPalettesInputSchema: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
4
+ export type ListPalettesInput = z.infer<typeof ListPalettesInputSchema>;
5
+ export interface PaletteSummary {
6
+ name: string;
7
+ label: string;
8
+ colors: string[];
9
+ }
10
+ export interface ListPalettesOutput {
11
+ palettes: PaletteSummary[];
12
+ }
13
+ export declare function listPalettesTool(): ToolResult<ListPalettesOutput>;
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ import { listPalettes } from '@blueprint-chart/lib';
3
+ import { toolOk } from '../errors';
4
+ export const ListPalettesInputSchema = z.object({}).strict();
5
+ export function listPalettesTool() {
6
+ const palettes = listPalettes().map(p => ({
7
+ name: p.name,
8
+ label: p.label,
9
+ colors: [...p.colors],
10
+ }));
11
+ return toolOk({ palettes });
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { listPalettesTool } from './listPalettes';
3
+ describe('list_palettes', () => {
4
+ it('returns named palettes with their colours', () => {
5
+ const r = listPalettesTool();
6
+ expect(r.ok).toBe(true);
7
+ if (r.ok) {
8
+ expect(r.data.palettes.length).toBeGreaterThan(0);
9
+ const first = r.data.palettes[0];
10
+ expect(first).toHaveProperty('name');
11
+ expect(Array.isArray(first.colors)).toBe(true);
12
+ expect(first.colors.length).toBeGreaterThan(0);
13
+ }
14
+ });
15
+ });
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { recommendCharts } from '@blueprint-chart/lib';
3
+ import { applyGoalReranking } from '../dsl/goalRanking';
3
4
  import { ErrorCode, toolError, toolOk } from '../errors';
4
5
  const ColumnTypeSchema = z.enum(['string', 'number', 'date']);
5
6
  export const RecommendInputSchema = z.object({
@@ -12,6 +13,7 @@ export function recommendChartType(input) {
12
13
  if (!parsed.success) {
13
14
  return toolError(ErrorCode.E_INPUT, parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })));
14
15
  }
15
- const recommendations = recommendCharts(parsed.data.columnTypes, parsed.data.rowCount);
16
+ const base = recommendCharts(parsed.data.columnTypes, parsed.data.rowCount);
17
+ const recommendations = applyGoalReranking(base, parsed.data.goal, parsed.data.rowCount);
16
18
  return toolOk({ recommendations });
17
19
  }
@@ -30,4 +30,44 @@ describe('recommend_chart_type', () => {
30
30
  expect(r.code).toBe('E_INPUT');
31
31
  }
32
32
  });
33
+ it('reorders toward part-to-whole when a goal says "share of total"', () => {
34
+ const r = recommendChartType({ columnTypes: ['string', 'number'], rowCount: 5, goal: 'each region as a share of the total' });
35
+ expect(r.ok).toBe(true);
36
+ if (r.ok) {
37
+ expect(['pie', 'donut', 'bar-stacked', 'column-stacked']).toContain(r.data.recommendations[0]?.chartType);
38
+ }
39
+ });
40
+ it('keeps lib-best bar-multi ahead of a narrative-boosted line-multi', () => {
41
+ const r = recommendChartType({
42
+ columnTypes: ['string', 'number', 'number', 'number'],
43
+ rowCount: 6,
44
+ goal: 'software overtakes hardware as the top revenue driver',
45
+ });
46
+ expect(r.ok).toBe(true);
47
+ if (r.ok) {
48
+ const types = r.data.recommendations.map(x => x.chartType);
49
+ const barMulti = types.indexOf('bar-multi');
50
+ const lineMulti = types.indexOf('line-multi');
51
+ expect(barMulti).toBeGreaterThanOrEqual(0);
52
+ expect(lineMulti).toBeGreaterThanOrEqual(0);
53
+ expect(barMulti).toBeLessThan(lineMulti);
54
+ }
55
+ });
56
+ it('threads rowCount so the pie tiebreak fires for a small-N part-to-whole goal', () => {
57
+ const r = recommendChartType({
58
+ columnTypes: ['string', 'number'],
59
+ rowCount: 5,
60
+ goal: 'Asia is nearly 60% of the world population; share of total across five regions',
61
+ });
62
+ expect(r.ok).toBe(true);
63
+ if (r.ok) {
64
+ const types = r.data.recommendations.map(x => x.chartType);
65
+ const pieIdx = types.indexOf('pie');
66
+ const donutIdx = types.indexOf('donut');
67
+ // both should be present and boosted to the front; pie before donut at N=5
68
+ expect(pieIdx).toBeGreaterThanOrEqual(0);
69
+ expect(donutIdx).toBeGreaterThanOrEqual(0);
70
+ expect(pieIdx).toBeLessThan(donutIdx);
71
+ }
72
+ });
33
73
  });
@@ -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
  }