Skip to content

Commit 5b4641d

Browse files
authored
feat(react): component improvements and MCP server package (#76)
* docs: add MCP server design spec for @tiny-design/mcp * docs: add @tiny-design/mcp implementation plan * feat(mcp): scaffold @tiny-design/mcp package * feat(mcp): add icon extractor * feat(mcp): add token extractor * feat(mcp): add component extractor with ts-morph * feat(mcp): add extraction orchestrator Ties the three extractors together, writing components.json, tokens.json, and icons.json to src/data/. Also fixes __dirname usage in all extractor scripts by adding ESM-compatible fileURLToPath shims (package uses type=module). * fix(mcp): fix ESM/CJS compatibility for jest and tsx * feat(mcp): add component tool handlers * feat(mcp): add token tool handler * feat(mcp): add icon tool handlers * feat(mcp): add MCP server entry point with 6 tools * fix(mcp): inline JSON data via static imports for self-contained bundle Switch tool handlers from readFileSync to static JSON imports so tsup inlines the data into dist/index.js. Also read version from package.json to prevent drift. * chore: add changeset for component improvements and MCP server * chore: update docs * chore: remove plan files
1 parent d6b25e3 commit 5b4641d

23 files changed

Lines changed: 1820 additions & 27 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tiny-design/react": patch
3+
---
4+
5+
Improve color-picker, slider, split, popup, and input-otp components; add @tiny-design/mcp server package with component, token, and icon tools

packages/mcp/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
src/data/
2+
dist/

packages/mcp/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# @tiny-design/mcp
2+
3+
MCP (Model Context Protocol) server that gives AI assistants structured access to the [Tiny Design](https://wangdicoder.github.io/tiny-design/) component library, design tokens, and icon catalog.
4+
5+
## Setup
6+
7+
Add to your MCP client config:
8+
9+
### Claude Code
10+
11+
```json
12+
// .claude/settings.json
13+
{
14+
"mcpServers": {
15+
"tiny-design": {
16+
"command": "npx",
17+
"args": ["@tiny-design/mcp"]
18+
}
19+
}
20+
}
21+
```
22+
23+
### VS Code (GitHub Copilot)
24+
25+
```json
26+
// .vscode/mcp.json
27+
{
28+
"mcpServers": {
29+
"tiny-design": {
30+
"command": "npx",
31+
"args": ["@tiny-design/mcp"]
32+
}
33+
}
34+
}
35+
```
36+
37+
### Cursor
38+
39+
```json
40+
// .cursor/mcp.json
41+
{
42+
"mcpServers": {
43+
"tiny-design": {
44+
"command": "npx",
45+
"args": ["@tiny-design/mcp"]
46+
}
47+
}
48+
}
49+
```
50+
51+
## Available Tools
52+
53+
| Tool | Description |
54+
|------|-------------|
55+
| `list_components` | List all 80+ components. Filter by category: Foundation, Layout, Navigation, Data Display, Form, Feedback, Miscellany. |
56+
| `get_component_props` | Get the full props interface for a component — types, required flags, descriptions. |
57+
| `get_component_example` | Get usage examples (demo code) for a component. |
58+
| `get_design_tokens` | Get design tokens (SCSS variables) — colors, typography, spacing, breakpoints, shadows. |
59+
| `list_icons` | List all 240+ icon names. Filter by search term. |
60+
| `get_icon` | Get details and usage example for a specific icon. |
61+
62+
## Examples
63+
64+
Ask your AI assistant:
65+
66+
- "List all form components in Tiny Design"
67+
- "What props does the Modal component accept?"
68+
- "Show me an example of using the Select component"
69+
- "What colors are available in Tiny Design's design tokens?"
70+
- "Find icons related to arrows"
71+
72+
## Development
73+
74+
```bash
75+
# Install dependencies (from monorepo root)
76+
pnpm install
77+
78+
# Run extraction + build
79+
pnpm --filter @tiny-design/mcp build
80+
81+
# Run tests
82+
pnpm --filter @tiny-design/mcp test
83+
```
84+
85+
### How It Works
86+
87+
1. **Build time:** Extraction scripts parse component `types.ts` files (via `ts-morph`), SCSS token variables (via regex), and icon barrel exports to produce static JSON.
88+
2. **Bundle time:** `tsup` bundles the server code with inlined JSON data into a single self-contained `dist/index.js`.
89+
3. **Runtime:** The MCP server loads the inlined data and serves it via 6 tools over stdio transport using `@modelcontextprotocol/sdk`.
90+
91+
## License
92+
93+
MIT
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { extractComponents } from '../scripts/extract-components';
2+
3+
describe('extractComponents', () => {
4+
let components: ReturnType<typeof extractComponents>;
5+
6+
beforeAll(() => {
7+
components = extractComponents();
8+
});
9+
10+
it('extracts all components', () => {
11+
expect(components.length).toBeGreaterThan(80);
12+
});
13+
14+
it('extracts Button component correctly', () => {
15+
const button = components.find((c) => c.name === 'Button');
16+
expect(button).toBeDefined();
17+
expect(button!.category).toBe('Foundation');
18+
expect(button!.description).toBe('To trigger an operation.');
19+
20+
const btnType = button!.props.find((p) => p.name === 'btnType');
21+
expect(btnType).toBeDefined();
22+
expect(btnType!.required).toBe(false);
23+
expect(btnType!.type).toContain('primary');
24+
25+
const style = button!.props.find((p) => p.name === 'style');
26+
expect(style).toBeDefined();
27+
});
28+
29+
it('extracts demo files', () => {
30+
const button = components.find((c) => c.name === 'Button');
31+
expect(button!.demos.length).toBeGreaterThan(0);
32+
expect(button!.demos[0].name).toBeDefined();
33+
expect(button!.demos[0].code).toContain('import');
34+
});
35+
36+
it('assigns categories to all components', () => {
37+
const validCategories = [
38+
'Foundation', 'Layout', 'Navigation', 'Data Display',
39+
'Form', 'Feedback', 'Miscellany',
40+
];
41+
components.forEach((c) => {
42+
expect(validCategories).toContain(c.category);
43+
});
44+
});
45+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { extractIcons } from '../scripts/extract-icons';
2+
3+
describe('extractIcons', () => {
4+
it('returns icon names and shared props', () => {
5+
const result = extractIcons();
6+
7+
// Check structure
8+
expect(result.props).toEqual({
9+
size: { type: 'string | number', default: '"1em"' },
10+
color: { type: 'string', default: '"currentColor"' },
11+
});
12+
13+
// Check icons array is populated
14+
expect(result.icons.length).toBeGreaterThan(200);
15+
16+
// Check specific known icons exist
17+
expect(result.icons).toContain('IconPlus');
18+
expect(result.icons).toContain('IconClose');
19+
expect(result.icons).toContain('IconHeart');
20+
21+
// All entries should start with "Icon"
22+
result.icons.forEach((name) => {
23+
expect(name).toMatch(/^Icon[A-Z]/);
24+
});
25+
});
26+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { extractTokens } from '../scripts/extract-tokens';
2+
3+
describe('extractTokens', () => {
4+
it('extracts tokens grouped by category', () => {
5+
const result = extractTokens();
6+
7+
// Should have known categories
8+
expect(Object.keys(result)).toEqual(
9+
expect.arrayContaining(['colors', 'typography', 'spacing', 'breakpoints', 'shadows'])
10+
);
11+
});
12+
13+
it('extracts color tokens', () => {
14+
const result = extractTokens();
15+
16+
expect(result.colors['primary-color']).toEqual({
17+
variable: '$primary-color',
18+
value: '#6e41bf',
19+
});
20+
21+
expect(result.colors['info-color']).toEqual({
22+
variable: '$info-color',
23+
value: '#1890ff',
24+
});
25+
});
26+
27+
it('extracts typography tokens', () => {
28+
const result = extractTokens();
29+
30+
expect(result.typography['font-size-base']).toEqual({
31+
variable: '$font-size-base',
32+
value: '1rem',
33+
});
34+
});
35+
36+
it('extracts breakpoint tokens', () => {
37+
const result = extractTokens();
38+
39+
expect(result.breakpoints['size-xs']).toEqual({
40+
variable: '$size-xs',
41+
value: '480px',
42+
});
43+
});
44+
45+
it('extracts shadow tokens', () => {
46+
const result = extractTokens();
47+
48+
expect(result.shadows).toBeDefined();
49+
expect(result.shadows['box-shadow-sm']).toBeDefined();
50+
});
51+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { listComponents, getComponentProps, getComponentExample } from '../src/tools/components';
2+
3+
describe('listComponents', () => {
4+
it('returns all components', () => {
5+
const result = listComponents();
6+
expect(result.length).toBeGreaterThan(80);
7+
expect(result[0]).toHaveProperty('name');
8+
expect(result[0]).toHaveProperty('category');
9+
expect(result[0]).toHaveProperty('description');
10+
expect(result[0]).not.toHaveProperty('props');
11+
expect(result[0]).not.toHaveProperty('demos');
12+
});
13+
14+
it('filters by category', () => {
15+
const result = listComponents('Foundation');
16+
expect(result.length).toBeGreaterThan(0);
17+
result.forEach((c) => expect(c.category).toBe('Foundation'));
18+
});
19+
20+
it('returns empty array for unknown category', () => {
21+
expect(listComponents('Unknown')).toEqual([]);
22+
});
23+
});
24+
25+
describe('getComponentProps', () => {
26+
it('returns props for a known component', () => {
27+
const result = getComponentProps('Button');
28+
expect(result).not.toBeNull();
29+
expect(result!.name).toBe('Button');
30+
expect(result!.props.length).toBeGreaterThan(0);
31+
expect(result!.props.find((p) => p.name === 'btnType')).toBeDefined();
32+
});
33+
34+
it('is case-insensitive', () => {
35+
const result = getComponentProps('button');
36+
expect(result).not.toBeNull();
37+
expect(result!.name).toBe('Button');
38+
});
39+
40+
it('returns null for unknown component', () => {
41+
expect(getComponentProps('FooBar')).toBeNull();
42+
});
43+
});
44+
45+
describe('getComponentExample', () => {
46+
it('returns all demos for a component', () => {
47+
const result = getComponentExample('Button');
48+
expect(result).not.toBeNull();
49+
expect(result!.length).toBeGreaterThan(0);
50+
expect(result![0].code).toContain('import');
51+
});
52+
53+
it('returns specific demo by name', () => {
54+
const result = getComponentExample('Button', 'Type');
55+
expect(result).not.toBeNull();
56+
expect(result!.length).toBe(1);
57+
expect(result![0].name).toBe('Type');
58+
});
59+
60+
it('returns null for unknown component', () => {
61+
expect(getComponentExample('FooBar')).toBeNull();
62+
});
63+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { listIcons, getIcon } from '../src/tools/icons';
2+
3+
describe('listIcons', () => {
4+
it('returns all icons', () => {
5+
const result = listIcons();
6+
expect(result.length).toBeGreaterThan(200);
7+
});
8+
9+
it('filters by search term', () => {
10+
const result = listIcons('arrow');
11+
expect(result.length).toBeGreaterThan(0);
12+
result.forEach((name) => {
13+
expect(name.toLowerCase()).toContain('arrow');
14+
});
15+
});
16+
17+
it('returns empty for no matches', () => {
18+
expect(listIcons('zzzznotanicon')).toEqual([]);
19+
});
20+
});
21+
22+
describe('getIcon', () => {
23+
it('returns icon details', () => {
24+
const result = getIcon('IconPlus');
25+
expect(result).not.toBeNull();
26+
expect(result!.name).toBe('IconPlus');
27+
expect(result!.props).toBeDefined();
28+
expect(result!.usage).toContain('IconPlus');
29+
});
30+
31+
it('is case-insensitive', () => {
32+
const result = getIcon('iconplus');
33+
expect(result).not.toBeNull();
34+
});
35+
36+
it('returns null for unknown icon', () => {
37+
expect(getIcon('IconFooBar')).toBeNull();
38+
});
39+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { getDesignTokens } from '../src/tools/tokens';
2+
3+
describe('getDesignTokens', () => {
4+
it('returns all token categories', () => {
5+
const result = getDesignTokens();
6+
expect(Object.keys(result)).toEqual(
7+
expect.arrayContaining(['colors', 'typography', 'spacing', 'breakpoints', 'shadows'])
8+
);
9+
});
10+
11+
it('filters by category', () => {
12+
const result = getDesignTokens('colors');
13+
expect(Object.keys(result)).toEqual(['colors']);
14+
expect(Object.keys(result.colors).length).toBeGreaterThan(0);
15+
});
16+
17+
it('returns empty object for unknown category', () => {
18+
const result = getDesignTokens('unknown');
19+
expect(result).toEqual({});
20+
});
21+
});

packages/mcp/jest.config.cjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/** @type {import('jest').Config} */
2+
module.exports = {
3+
testEnvironment: 'node',
4+
roots: ['<rootDir>/__tests__'],
5+
extensionsToTreatAsEsm: ['.ts'],
6+
transform: {
7+
'^.+\\.tsx?$': ['ts-jest', {
8+
useESM: true,
9+
tsconfig: {
10+
module: 'ESNext',
11+
moduleResolution: 'bundler',
12+
target: 'ES2022',
13+
esModuleInterop: true,
14+
resolveJsonModule: true,
15+
strict: true,
16+
},
17+
}],
18+
},
19+
moduleNameMapper: {
20+
'^(\\.{1,2}/.*)\\.js$': '$1',
21+
},
22+
};

0 commit comments

Comments
 (0)