@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.
- package/README.md +31 -15
- package/dist/cli.js +15 -2
- package/dist/dsl/capabilityMatrix.d.ts +22 -0
- package/dist/dsl/capabilityMatrix.js +37 -0
- package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
- package/dist/dsl/capabilityMatrix.test.js +49 -0
- 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/goalRanking.d.ts +7 -0
- package/dist/dsl/goalRanking.js +76 -0
- package/dist/dsl/goalRanking.test.d.ts +1 -0
- package/dist/dsl/goalRanking.test.js +83 -0
- package/dist/dsl/parseErrorHints.d.ts +12 -0
- package/dist/dsl/parseErrorHints.js +32 -0
- package/dist/dsl/parseErrorHints.test.d.ts +1 -0
- package/dist/dsl/parseErrorHints.test.js +26 -0
- package/dist/dsl/semanticWarnings.d.ts +7 -0
- package/dist/dsl/semanticWarnings.js +66 -0
- package/dist/dsl/semanticWarnings.test.d.ts +1 -0
- package/dist/dsl/semanticWarnings.test.js +32 -0
- package/dist/dsl/suggest.d.ts +1 -0
- package/dist/dsl/suggest.js +66 -0
- package/dist/dsl/suggest.test.d.ts +1 -0
- package/dist/dsl/suggest.test.js +34 -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/parse.js +14 -6
- package/dist/parse.test.js +8 -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 +75 -5
- package/dist/server.test.js +105 -4
- package/dist/tools/describeChartType.d.ts +41 -0
- package/dist/tools/describeChartType.js +143 -0
- package/dist/tools/describeChartType.test.d.ts +1 -0
- package/dist/tools/describeChartType.test.js +78 -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 +35 -0
- package/dist/tools/inspect.d.ts +8 -1
- package/dist/tools/inspect.js +40 -7
- package/dist/tools/inspect.test.js +62 -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/listPalettes.d.ts +13 -0
- package/dist/tools/listPalettes.js +12 -0
- package/dist/tools/listPalettes.test.d.ts +1 -0
- package/dist/tools/listPalettes.test.js +15 -0
- package/dist/tools/recommend.js +3 -1
- package/dist/tools/recommend.test.js +40 -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/searchExamples.d.ts +28 -0
- package/dist/tools/searchExamples.js +54 -0
- package/dist/tools/searchExamples.test.d.ts +1 -0
- package/dist/tools/searchExamples.test.js +32 -0
- package/dist/tools/validate.d.ts +9 -3
- package/dist/tools/validate.js +11 -1
- package/dist/tools/validate.test.js +33 -11
- 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 +5 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.png +0 -0
- 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 +
|
|
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);
|
|
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
|
+
});
|
package/dist/tools/recommend.js
CHANGED
|
@@ -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
|
|
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
|
});
|
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
|
}
|