@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.
- package/README.md +27 -14
- package/dist/cli.js +15 -2
- package/dist/dsl/chartTypes.d.ts +16 -0
- package/dist/dsl/chartTypes.js +37 -0
- package/dist/dsl/chartTypes.test.d.ts +1 -0
- package/dist/dsl/chartTypes.test.js +32 -0
- package/dist/dsl/dataKey.d.ts +25 -0
- package/dist/dsl/dataKey.js +42 -0
- package/dist/dsl/dataKey.test.d.ts +1 -0
- package/dist/dsl/dataKey.test.js +35 -0
- package/dist/dsl/suggest.d.ts +1 -0
- package/dist/dsl/suggest.js +47 -0
- package/dist/dsl/suggest.test.d.ts +1 -0
- package/dist/dsl/suggest.test.js +20 -0
- package/dist/dsl/universalProperties.d.ts +30 -0
- package/dist/dsl/universalProperties.js +52 -0
- package/dist/dsl/universalProperties.test.d.ts +1 -0
- package/dist/dsl/universalProperties.test.js +26 -0
- package/dist/dsl/validate.d.ts +10 -0
- package/dist/dsl/validate.js +68 -0
- package/dist/dsl/validate.test.d.ts +1 -0
- package/dist/dsl/validate.test.js +73 -0
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +1 -0
- package/dist/errors.test.js +21 -0
- package/dist/lib/zodToJsonSchema.d.ts +10 -5
- package/dist/lib/zodToJsonSchema.js +14 -6
- package/dist/links/buildUrls.d.ts +14 -0
- package/dist/links/buildUrls.js +20 -0
- package/dist/links/buildUrls.test.d.ts +1 -0
- package/dist/links/buildUrls.test.js +28 -0
- package/dist/links/editorConfig.d.ts +4 -0
- package/dist/links/editorConfig.js +15 -0
- package/dist/links/editorConfig.test.d.ts +1 -0
- package/dist/links/editorConfig.test.js +28 -0
- package/dist/links/encode.d.ts +11 -0
- package/dist/links/encode.js +19 -0
- package/dist/links/encode.test.d.ts +1 -0
- package/dist/links/encode.test.js +37 -0
- package/dist/prompts/authorChart.js +23 -18
- package/dist/prompts/authorChart.test.js +6 -0
- package/dist/render/diagnose.d.ts +19 -0
- package/dist/render/diagnose.js +100 -0
- package/dist/render/diagnose.test.d.ts +1 -0
- package/dist/render/diagnose.test.js +53 -0
- package/dist/render/frame.d.ts +10 -0
- package/dist/render/frame.js +10 -0
- package/dist/render/frame.test.d.ts +1 -0
- package/dist/render/frame.test.js +12 -0
- package/dist/render/jsdomEnv.d.ts +2 -1
- package/dist/render/jsdomEnv.js +14 -1
- package/dist/render/jsdomEnv.test.js +36 -2
- package/dist/render/renderSceneState.d.ts +5 -1
- package/dist/render/renderSceneState.js +4 -3
- package/dist/render/renderSceneState.test.js +13 -7
- package/dist/render/validatePipeline.d.ts +23 -0
- package/dist/render/validatePipeline.js +41 -0
- package/dist/render/validatePipeline.test.d.ts +1 -0
- package/dist/render/validatePipeline.test.js +34 -0
- package/dist/resources/docsReader.d.ts +4 -1
- package/dist/resources/docsReader.js +23 -6
- package/dist/resources/docsReader.test.js +27 -2
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/samples.d.ts +1 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +63 -5
- package/dist/server.test.js +101 -4
- package/dist/tools/describeChartType.d.ts +33 -0
- package/dist/tools/describeChartType.js +119 -0
- package/dist/tools/describeChartType.test.d.ts +1 -0
- package/dist/tools/describeChartType.test.js +58 -0
- package/dist/tools/exportChart.d.ts +17 -0
- package/dist/tools/exportChart.js +31 -0
- package/dist/tools/exportChart.test.d.ts +1 -0
- package/dist/tools/exportChart.test.js +43 -0
- package/dist/tools/getExample.d.ts +20 -0
- package/dist/tools/getExample.js +55 -0
- package/dist/tools/getExample.test.d.ts +1 -0
- package/dist/tools/getExample.test.js +40 -0
- package/dist/tools/getGrammar.d.ts +17 -0
- package/dist/tools/getGrammar.js +38 -0
- package/dist/tools/getGrammar.test.d.ts +1 -0
- package/dist/tools/getGrammar.test.js +24 -0
- package/dist/tools/inspect.d.ts +8 -1
- package/dist/tools/inspect.js +31 -7
- package/dist/tools/inspect.test.js +35 -13
- package/dist/tools/listChartTypes.d.ts +14 -0
- package/dist/tools/listChartTypes.js +42 -0
- package/dist/tools/listChartTypes.test.d.ts +1 -0
- package/dist/tools/listChartTypes.test.js +42 -0
- package/dist/tools/render.d.ts +14 -12
- package/dist/tools/render.js +96 -28
- package/dist/tools/render.test.js +137 -1
- package/dist/tools/validate.d.ts +11 -3
- package/dist/tools/validate.js +9 -1
- package/dist/tools/validate.test.js +17 -12
- package/dist/transports/http.d.ts +4 -2
- package/dist/transports/http.js +232 -23
- package/dist/transports/http.test.js +158 -22
- package/package.json +4 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +9 -0
package/dist/tools/inspect.d.ts
CHANGED
|
@@ -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>;
|
package/dist/tools/inspect.js
CHANGED
|
@@ -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
|
|
49
|
+
const ast = parsed.data.ast;
|
|
50
|
+
const def = astToDefinition(ast);
|
|
27
51
|
return toolOk({
|
|
28
52
|
chartType: def.chartType,
|
|
29
|
-
scenes: summarizeScenes(
|
|
53
|
+
scenes: summarizeScenes(ast),
|
|
54
|
+
data: summarizeData(ast),
|
|
30
55
|
hasAnnotations: (def.annotations?.length ?? 0) > 0,
|
|
31
|
-
hasColorizes: (
|
|
32
|
-
hasHighlights: (
|
|
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 +
|
|
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.
|
|
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
|
+
});
|
package/dist/tools/render.d.ts
CHANGED
|
@@ -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
|
|
29
|
+
svg?: string;
|
|
25
30
|
png?: string;
|
|
26
|
-
|
|
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>>;
|
package/dist/tools/render.js
CHANGED
|
@@ -1,63 +1,131 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/tools/validate.d.ts
CHANGED
|
@@ -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
|
|
12
|
-
valid:
|
|
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>;
|
package/dist/tools/validate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|