@starklab/stark-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +31 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
- package/src/adopt/catalog.js +88 -0
- package/src/adopt/dominionFixture.test.js +165 -0
- package/src/adopt/moduleGraph.js +232 -0
- package/src/adopt/parseSource.js +25 -0
- package/src/adopt/propApiResolver.js +278 -0
- package/src/adopt/propApiResolver.test.js +229 -0
- package/src/adopt/referenceResolver.js +151 -0
- package/src/adopt/referenceResolver.test.js +213 -0
- package/src/adopt/rnTailwindResolver.js +347 -0
- package/src/adopt/rnTailwindResolver.test.js +263 -0
- package/src/adopt/rnTokenAliasResolver.js +474 -0
- package/src/adopt/rnTokenAliasResolver.test.js +260 -0
- package/src/adopt/tailwindResolver.js +512 -0
- package/src/adopt/tailwindResolver.test.js +178 -0
- package/src/adopt/targetDiscovery.js +237 -0
- package/src/adopt/targetDiscovery.test.js +227 -0
- package/src/adopt/tokenAliasResolver.js +513 -0
- package/src/adopt/tokenAliasResolver.test.js +319 -0
- package/src/adopt/wrapperResolver.js +874 -0
- package/src/adopt/wrapperResolver.test.js +324 -0
- package/src/cli.js +376 -0
- package/src/data.js +267 -0
- package/src/data.test.js +231 -0
- package/src/index.js +8 -0
- package/src/server.js +149 -0
package/src/data.test.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
toSlug,
|
|
9
|
+
listComponents,
|
|
10
|
+
getComponentUsage,
|
|
11
|
+
getComponentProps,
|
|
12
|
+
getComponentTokens,
|
|
13
|
+
buildManifest,
|
|
14
|
+
getLayoutCatalog,
|
|
15
|
+
getLayoutSchema,
|
|
16
|
+
validateLayout,
|
|
17
|
+
getGenerationProtocol,
|
|
18
|
+
ejectComponent,
|
|
19
|
+
} from './data.js';
|
|
20
|
+
|
|
21
|
+
describe('toSlug', () => {
|
|
22
|
+
it('converts PascalCase to kebab-case', () => {
|
|
23
|
+
expect(toSlug('TextInput')).toBe('text-input');
|
|
24
|
+
expect(toSlug('BadgeStatus')).toBe('badge-status');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('is idempotent on already-kebab input', () => {
|
|
28
|
+
expect(toSlug('text-input')).toBe('text-input');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is case-insensitive for plain names', () => {
|
|
32
|
+
expect(toSlug('button')).toBe('button');
|
|
33
|
+
expect(toSlug('Button')).toBe('button');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('listComponents', () => {
|
|
38
|
+
it('includes Button with a resolved usage status', () => {
|
|
39
|
+
const components = listComponents();
|
|
40
|
+
const button = components.find(c => c.slug === 'button');
|
|
41
|
+
expect(button).toBeTruthy();
|
|
42
|
+
expect(button.name).toBe('Button');
|
|
43
|
+
expect(button.status).toBe('stable');
|
|
44
|
+
expect(button.description).toContain('action');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is sorted by name', () => {
|
|
48
|
+
const names = listComponents().map(c => c.name);
|
|
49
|
+
const sorted = [...names].sort((a, b) => a.localeCompare(b));
|
|
50
|
+
expect(names).toEqual(sorted);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getComponentUsage', () => {
|
|
55
|
+
it('returns dos/donts for a known component, case-insensitively', () => {
|
|
56
|
+
const usage = getComponentUsage('button');
|
|
57
|
+
expect(Array.isArray(usage.dos)).toBe(true);
|
|
58
|
+
expect(Array.isArray(usage.donts)).toBe(true);
|
|
59
|
+
expect(usage.dos.length).toBeGreaterThan(0);
|
|
60
|
+
|
|
61
|
+
const same = getComponentUsage('Button');
|
|
62
|
+
expect(same.dos).toEqual(usage.dos);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('throws a helpful error for an unknown component', () => {
|
|
66
|
+
expect(() => getComponentUsage('NotAComponent')).toThrow(/list_components/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('getComponentProps', () => {
|
|
71
|
+
it('returns the propMap for Button on web', () => {
|
|
72
|
+
const props = getComponentProps('Button', 'web');
|
|
73
|
+
expect(props.component).toBe('Button');
|
|
74
|
+
const variant = props.propMap.find(p => p.react === 'variant');
|
|
75
|
+
expect(variant).toBeTruthy();
|
|
76
|
+
expect(variant.values).toMatchObject({ primary: 'primary' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws for an unsupported platform', () => {
|
|
80
|
+
expect(() => getComponentProps('Button', 'not-a-platform')).toThrow(/does not support platform/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('omits tokens by default', () => {
|
|
84
|
+
const props = getComponentProps('Button', 'web');
|
|
85
|
+
expect(props.tokens).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('includes token JSON when includeTokens is true', () => {
|
|
89
|
+
const props = getComponentProps('Button', 'web', true);
|
|
90
|
+
expect(props.tokens).toBeTruthy();
|
|
91
|
+
expect(props.tokens.button).toBeTruthy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns null tokens for a component with no token file', () => {
|
|
95
|
+
const props = getComponentProps('Container', 'web', true);
|
|
96
|
+
expect(props.tokens).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('getComponentTokens', () => {
|
|
101
|
+
it('returns the DTCG token tree for a component that has one', () => {
|
|
102
|
+
const tokens = getComponentTokens('Button');
|
|
103
|
+
expect(tokens.button).toBeTruthy();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns null for a component with no token file', () => {
|
|
107
|
+
expect(getComponentTokens('Grid')).toBeNull();
|
|
108
|
+
expect(getComponentTokens('Stack')).toBeNull();
|
|
109
|
+
expect(getComponentTokens('RadioGroup')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('buildManifest', () => {
|
|
114
|
+
it('includes every listed component, with usage, props, and tokens rolled in', () => {
|
|
115
|
+
const manifest = buildManifest();
|
|
116
|
+
expect(manifest.componentCount).toBe(listComponents().length);
|
|
117
|
+
expect(manifest.components.length).toBe(manifest.componentCount);
|
|
118
|
+
|
|
119
|
+
const button = manifest.components.find(c => c.name === 'Button');
|
|
120
|
+
expect(button.usage.dos.length).toBeGreaterThan(0);
|
|
121
|
+
expect(button.props.web.propMap.length).toBeGreaterThan(0);
|
|
122
|
+
expect(button.tokens.button).toBeTruthy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles a component with no token file without throwing', () => {
|
|
126
|
+
const manifest = buildManifest();
|
|
127
|
+
const container = manifest.components.find(c => c.name === 'Container');
|
|
128
|
+
expect(container).toBeTruthy();
|
|
129
|
+
expect(container.tokens).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('only includes platforms the component actually supports', () => {
|
|
133
|
+
const manifest = buildManifest();
|
|
134
|
+
const button = manifest.components.find(c => c.name === 'Button');
|
|
135
|
+
expect(Object.keys(button.props)).toContain('web');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('layout catalog / schema', () => {
|
|
140
|
+
it('lists Button in the web layout catalog with nodeType', () => {
|
|
141
|
+
const catalog = getLayoutCatalog('web');
|
|
142
|
+
const entry = catalog.find(c => c.name === 'Button');
|
|
143
|
+
expect(entry).toBeTruthy();
|
|
144
|
+
expect(entry.schema.nodeType).toBe('Button');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('get_layout_schema returns the same entry as filtering the catalog', () => {
|
|
148
|
+
const schema = getLayoutSchema('Button', 'web');
|
|
149
|
+
expect(schema.schema.nodeType).toBe('Button');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('throws for a component with no standalone layout node', () => {
|
|
153
|
+
expect(() => getLayoutSchema('DefinitelyNotReal', 'web')).toThrow(/get_layout_catalog/);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('validateLayout', () => {
|
|
158
|
+
it('returns a findings array for an empty layout', () => {
|
|
159
|
+
const result = validateLayout({ layout: { page: { sections: [] } } });
|
|
160
|
+
expect(Array.isArray(result.findings)).toBe(true);
|
|
161
|
+
expect(typeof result.hasBlocking).toBe('boolean');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('ejectComponent', () => {
|
|
166
|
+
let tmpDir;
|
|
167
|
+
|
|
168
|
+
afterEach(() => {
|
|
169
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('copies a component\'s jsx and css into the target directory', () => {
|
|
173
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'stark-eject-test-'));
|
|
174
|
+
const target = path.join(tmpDir, 'Button');
|
|
175
|
+
const result = ejectComponent('button', { outDir: target });
|
|
176
|
+
|
|
177
|
+
expect(result.component).toBe('Button');
|
|
178
|
+
expect(result.files.sort()).toEqual(['Button.css', 'Button.jsx']);
|
|
179
|
+
expect(readdirSync(target).sort()).toEqual(['Button.css', 'Button.jsx']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('excludes .figma.tsx code-connect metadata', () => {
|
|
183
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'stark-eject-test-'));
|
|
184
|
+
const target = path.join(tmpDir, 'DataTable');
|
|
185
|
+
const result = ejectComponent('DataTable', { outDir: target });
|
|
186
|
+
|
|
187
|
+
expect(result.files.some(f => f.endsWith('.figma.tsx'))).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('throws for an unknown component', () => {
|
|
191
|
+
expect(() => ejectComponent('NotAComponent')).toThrow(/Unknown component/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('copies from stk-react-native when platform is native', () => {
|
|
195
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'stark-eject-test-'));
|
|
196
|
+
const target = path.join(tmpDir, 'Button');
|
|
197
|
+
const result = ejectComponent('Button', { outDir: target, platform: 'native' });
|
|
198
|
+
|
|
199
|
+
expect(result.platform).toBe('native');
|
|
200
|
+
expect(result.files).toEqual(['Button.jsx']);
|
|
201
|
+
expect(readdirSync(target)).toEqual(['Button.jsx']);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('throws for an unsupported eject platform', () => {
|
|
205
|
+
expect(() => ejectComponent('Button', { platform: 'not-a-platform' })).toThrow(/Unsupported platform/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('refuses to overwrite a non-empty target without force', () => {
|
|
209
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'stark-eject-test-'));
|
|
210
|
+
const target = path.join(tmpDir, 'Button');
|
|
211
|
+
ejectComponent('Button', { outDir: target });
|
|
212
|
+
|
|
213
|
+
expect(() => ejectComponent('Button', { outDir: target })).toThrow(/already exists/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('overwrites a non-empty target when force is set', () => {
|
|
217
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'stark-eject-test-'));
|
|
218
|
+
const target = path.join(tmpDir, 'Button');
|
|
219
|
+
ejectComponent('Button', { outDir: target });
|
|
220
|
+
|
|
221
|
+
expect(() => ejectComponent('Button', { outDir: target, force: true })).not.toThrow();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('getGenerationProtocol', () => {
|
|
226
|
+
it('returns the versioned step checklist', () => {
|
|
227
|
+
const protocol = getGenerationProtocol();
|
|
228
|
+
expect(protocol.version).toBeGreaterThanOrEqual(1);
|
|
229
|
+
expect(protocol.steps.length).toBeGreaterThan(0);
|
|
230
|
+
});
|
|
231
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
|
|
4
|
+
import { createServer } from './server.js';
|
|
5
|
+
|
|
6
|
+
const server = createServer();
|
|
7
|
+
const transport = new StdioServerTransport();
|
|
8
|
+
await server.connect(transport);
|
package/src/server.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
listComponents,
|
|
6
|
+
getComponentUsage,
|
|
7
|
+
getComponentProps,
|
|
8
|
+
buildManifest,
|
|
9
|
+
getLayoutCatalog,
|
|
10
|
+
getLayoutSchema,
|
|
11
|
+
validateLayout,
|
|
12
|
+
getGenerationProtocol,
|
|
13
|
+
} from './data.js';
|
|
14
|
+
|
|
15
|
+
const json = value => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
|
|
16
|
+
const error = message => ({ content: [{ type: 'text', text: message }], isError: true });
|
|
17
|
+
|
|
18
|
+
const wrap = fn => async args => {
|
|
19
|
+
try {
|
|
20
|
+
return json(await fn(args));
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return error(err instanceof Error ? err.message : String(err));
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function createServer() {
|
|
27
|
+
const server = new McpServer({ name: 'stark-design-system', version: '0.1.0' });
|
|
28
|
+
|
|
29
|
+
server.registerTool(
|
|
30
|
+
'list_components',
|
|
31
|
+
{
|
|
32
|
+
title: 'List Stark components',
|
|
33
|
+
description:
|
|
34
|
+
'List every component in the Stark design system catalog with its status ' +
|
|
35
|
+
'(stable/beta/experimental/deprecated), one-line description, and supported platforms.',
|
|
36
|
+
inputSchema: {},
|
|
37
|
+
},
|
|
38
|
+
wrap(() => listComponents())
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
server.registerTool(
|
|
42
|
+
'get_component_usage',
|
|
43
|
+
{
|
|
44
|
+
title: 'Get component usage rules',
|
|
45
|
+
description:
|
|
46
|
+
'Get the dos/donts, prop table, and Figma link for one Stark component. ' +
|
|
47
|
+
'Read this before placing the component in a layout.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
component: z.string().describe('Component name, e.g. "Button" or "TextInput" (case-insensitive)'),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
wrap(({ component }) => getComponentUsage(component))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
server.registerTool(
|
|
56
|
+
'get_component_props',
|
|
57
|
+
{
|
|
58
|
+
title: 'Get component prop mapping',
|
|
59
|
+
description:
|
|
60
|
+
'Get the React<->Figma prop translation table for one component: prop names, ' +
|
|
61
|
+
'types, enum values, and defaults.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
component: z.string().describe('Component name, e.g. "Button"'),
|
|
64
|
+
platform: z.enum(['web', 'native']).default('web').describe('Target platform'),
|
|
65
|
+
includeTokens: z
|
|
66
|
+
.boolean()
|
|
67
|
+
.default(false)
|
|
68
|
+
.describe(
|
|
69
|
+
'Also return the component-level design token JSON (spacing, radius, color aliases…) in a ' +
|
|
70
|
+
'"tokens" field. null if the component has no component-layer tokens of its own (e.g. layout primitives).'
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
wrap(({ component, platform, includeTokens }) => getComponentProps(component, platform, includeTokens))
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
server.registerTool(
|
|
78
|
+
'get_manifest',
|
|
79
|
+
{
|
|
80
|
+
title: 'Get the full design system manifest',
|
|
81
|
+
description:
|
|
82
|
+
'Get every component in one call — usage rules, prop mappings for every supported platform, ' +
|
|
83
|
+
'and token JSON — instead of making list_components + get_component_usage + get_component_props ' +
|
|
84
|
+
'per component. Useful for a one-shot snapshot of the whole system.',
|
|
85
|
+
inputSchema: {},
|
|
86
|
+
},
|
|
87
|
+
wrap(() => buildManifest())
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
server.registerTool(
|
|
91
|
+
'get_layout_catalog',
|
|
92
|
+
{
|
|
93
|
+
title: 'Get the full layout catalog',
|
|
94
|
+
description:
|
|
95
|
+
'Get every component that can be used as a standalone layout node, with its props ' +
|
|
96
|
+
'and slots. This is the same catalog Stark\'s own layout generator (Vecna) is restricted to.',
|
|
97
|
+
inputSchema: {
|
|
98
|
+
platform: z.enum(['web', 'native']).default('web').describe('Target platform'),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
wrap(({ platform }) => getLayoutCatalog(platform))
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
server.registerTool(
|
|
105
|
+
'get_layout_schema',
|
|
106
|
+
{
|
|
107
|
+
title: 'Get one component\'s layout schema',
|
|
108
|
+
description: 'Get the props/slots schema for a single layout-capable component.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
component: z.string().describe('Component name, e.g. "Card"'),
|
|
111
|
+
platform: z.enum(['web', 'native']).default('web').describe('Target platform'),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
wrap(({ component, platform }) => getLayoutSchema(component, platform))
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
server.registerTool(
|
|
118
|
+
'validate_layout',
|
|
119
|
+
{
|
|
120
|
+
title: 'Validate a generated layout',
|
|
121
|
+
description:
|
|
122
|
+
'Run Stark\'s deterministic conformance checks (no LLM) against a LayoutConfig JSON: ' +
|
|
123
|
+
'catalog membership, component do/dont rules, guardrails, and prop shape. Returns findings ' +
|
|
124
|
+
'ranked Critical/Warning/Info. Pass `canvasCases` (your renderer\'s supported node-type list) ' +
|
|
125
|
+
'if you have one — without it, the catalog<->renderer parity check is skipped.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
layout: z.record(z.string(), z.any()).describe('The LayoutConfig JSON, shaped { page: { sections: [...] } }'),
|
|
128
|
+
intent: z.record(z.string(), z.any()).optional().describe('Intent/guardrails object the layout was generated from'),
|
|
129
|
+
platform: z.enum(['web', 'native']).default('web'),
|
|
130
|
+
canvasCases: z.array(z.string()).optional().describe('Node types your renderer actually supports'),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
wrap(({ layout, intent, platform, canvasCases }) => validateLayout({ layout, intent, platform, canvasCases }))
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
server.registerTool(
|
|
137
|
+
'get_generation_protocol',
|
|
138
|
+
{
|
|
139
|
+
title: 'Get the layout generation protocol',
|
|
140
|
+
description:
|
|
141
|
+
'Get the enforced step-by-step checklist an agent must follow before a generated layout ' +
|
|
142
|
+
'is considered done — the same checklist the Vecna orchestrator runs.',
|
|
143
|
+
inputSchema: {},
|
|
144
|
+
},
|
|
145
|
+
wrap(() => getGenerationProtocol())
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return server;
|
|
149
|
+
}
|