@urbicon-ui/mcp-server 6.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +161 -0
  2. package/dist/data/catalog-loader.d.ts +37 -0
  3. package/dist/data/catalog-loader.d.ts.map +1 -0
  4. package/dist/data/catalog-loader.js +15 -0
  5. package/dist/data/catalog-loader.js.map +1 -0
  6. package/dist/data/component-loader.d.ts +2 -0
  7. package/dist/data/component-loader.d.ts.map +1 -0
  8. package/dist/data/component-loader.js +17 -0
  9. package/dist/data/component-loader.js.map +1 -0
  10. package/dist/data/recipe-loader.d.ts +4 -0
  11. package/dist/data/recipe-loader.d.ts.map +1 -0
  12. package/dist/data/recipe-loader.js +102 -0
  13. package/dist/data/recipe-loader.js.map +1 -0
  14. package/dist/data/template-loader.d.ts +8 -0
  15. package/dist/data/template-loader.d.ts.map +1 -0
  16. package/dist/data/template-loader.js +33 -0
  17. package/dist/data/template-loader.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +57 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/resources/catalog.d.ts +3 -0
  23. package/dist/resources/catalog.d.ts.map +1 -0
  24. package/dist/resources/catalog.js +20 -0
  25. package/dist/resources/catalog.js.map +1 -0
  26. package/dist/resources/component.d.ts +3 -0
  27. package/dist/resources/component.d.ts.map +1 -0
  28. package/dist/resources/component.js +29 -0
  29. package/dist/resources/component.js.map +1 -0
  30. package/dist/resources/guides.d.ts +3 -0
  31. package/dist/resources/guides.d.ts.map +1 -0
  32. package/dist/resources/guides.js +36 -0
  33. package/dist/resources/guides.js.map +1 -0
  34. package/dist/server.d.ts +3 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js.map +1 -0
  37. package/dist/tools/find-components.d.ts +3 -0
  38. package/dist/tools/find-components.d.ts.map +1 -0
  39. package/dist/tools/find-components.js +21 -0
  40. package/dist/tools/find-components.js.map +1 -0
  41. package/dist/tools/get-recipe.d.ts +3 -0
  42. package/dist/tools/get-recipe.d.ts.map +1 -0
  43. package/dist/tools/get-recipe.js +48 -0
  44. package/dist/tools/get-recipe.js.map +1 -0
  45. package/dist/tools/suggest-implementation.d.ts +3 -0
  46. package/dist/tools/suggest-implementation.d.ts.map +1 -0
  47. package/dist/tools/suggest-implementation.js +178 -0
  48. package/dist/tools/suggest-implementation.js.map +1 -0
  49. package/dist/transports/http.d.ts +2 -0
  50. package/dist/transports/http.d.ts.map +1 -0
  51. package/dist/transports/http.js +77 -0
  52. package/dist/transports/http.js.map +1 -0
  53. package/dist/transports/stdio.d.ts +3 -0
  54. package/dist/transports/stdio.d.ts.map +1 -0
  55. package/dist/transports/stdio.js +6 -0
  56. package/dist/transports/stdio.js.map +1 -0
  57. package/dist/tsconfig.tsbuildinfo +1 -0
  58. package/dist/utils/format-catalog.d.ts +7 -0
  59. package/dist/utils/format-catalog.d.ts.map +1 -0
  60. package/dist/utils/format-catalog.js +93 -0
  61. package/dist/utils/format-catalog.js.map +1 -0
  62. package/dist/utils/paths.d.ts +7 -0
  63. package/dist/utils/paths.d.ts.map +1 -0
  64. package/dist/utils/paths.js +23 -0
  65. package/dist/utils/paths.js.map +1 -0
  66. package/dist/utils/search.d.ts +3 -0
  67. package/dist/utils/search.d.ts.map +1 -0
  68. package/dist/utils/search.js +44 -0
  69. package/dist/utils/search.js.map +1 -0
  70. package/package.json +42 -0
  71. package/src/data/catalog-loader.test.ts +42 -0
  72. package/src/data/catalog-loader.ts +78 -0
  73. package/src/data/component-loader.ts +68 -0
  74. package/src/data/design-system-loader.test.ts +82 -0
  75. package/src/data/design-system-loader.ts +125 -0
  76. package/src/data/icon-loader.test.ts +85 -0
  77. package/src/data/icon-loader.ts +90 -0
  78. package/src/data/recipe-loader.test.ts +49 -0
  79. package/src/data/recipe-loader.ts +131 -0
  80. package/src/data/template-loader.ts +55 -0
  81. package/src/design-linter/heuristics.ts +162 -0
  82. package/src/design-linter/index.ts +14 -0
  83. package/src/design-linter/linter.test.ts +257 -0
  84. package/src/design-linter/linter.ts +62 -0
  85. package/src/design-linter/rules.ts +348 -0
  86. package/src/design-linter/tokens.test.ts +80 -0
  87. package/src/design-linter/tokens.ts +203 -0
  88. package/src/design-linter/types.ts +66 -0
  89. package/src/design-manifest/index.ts +20 -0
  90. package/src/design-manifest/manifest.test.ts +175 -0
  91. package/src/design-manifest/manifest.ts +250 -0
  92. package/src/design-manifest/scan.test.ts +51 -0
  93. package/src/design-manifest/scan.ts +74 -0
  94. package/src/design-manifest/types.ts +40 -0
  95. package/src/design-rubric/rubric.test.ts +43 -0
  96. package/src/design-rubric/rubric.ts +140 -0
  97. package/src/eval/briefs.ts +104 -0
  98. package/src/eval/eval.test.ts +99 -0
  99. package/src/eval/index.ts +11 -0
  100. package/src/eval/score.ts +112 -0
  101. package/src/index.ts +75 -0
  102. package/src/prompts/design-prompts.test.ts +51 -0
  103. package/src/prompts/design-prompts.ts +127 -0
  104. package/src/resources/catalog.ts +23 -0
  105. package/src/resources/guides.ts +60 -0
  106. package/src/server.test.ts +69 -0
  107. package/src/server.ts +48 -0
  108. package/src/tools/find-components.ts +83 -0
  109. package/src/tools/find-icons.ts +77 -0
  110. package/src/tools/get-checklist.ts +139 -0
  111. package/src/tools/get-component.ts +204 -0
  112. package/src/tools/get-css-reference.ts +446 -0
  113. package/src/tools/get-design-context.ts +43 -0
  114. package/src/tools/get-design-principles.ts +72 -0
  115. package/src/tools/get-pattern.ts +69 -0
  116. package/src/tools/get-recipe.ts +80 -0
  117. package/src/tools/record-design-decision.ts +99 -0
  118. package/src/tools/suggest-implementation.ts +251 -0
  119. package/src/tools/sync-design-manifest.ts +92 -0
  120. package/src/tools/validate-design.ts +84 -0
  121. package/src/transports/http.ts +79 -0
  122. package/src/transports/stdio.ts +7 -0
  123. package/src/utils/format-catalog.test.ts +144 -0
  124. package/src/utils/format-catalog.ts +130 -0
  125. package/src/utils/paths.test.ts +101 -0
  126. package/src/utils/paths.ts +78 -0
  127. package/src/utils/search.test.ts +141 -0
  128. package/src/utils/search.ts +106 -0
  129. package/tsconfig.json +27 -0
  130. package/vitest.config.ts +15 -0
@@ -0,0 +1,92 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { z } from 'zod';
5
+ import {
6
+ createManifestTemplate,
7
+ scanMarkers,
8
+ upsertUsagesSection
9
+ } from '../design-manifest/index.js';
10
+ import { getProjectManifestPath, getProjectSourceDir, isWithinProjectDir } from '../utils/paths.js';
11
+
12
+ export function registerSyncDesignManifestTool(server: McpServer): void {
13
+ server.tool(
14
+ 'sync_design_manifest',
15
+ 'Scan the project source for `data-design-pattern="…"` markers and regenerate the Pattern Usages section of design.manifest.md. Run after adding, moving, or removing pattern-following pages so the usage index stays accurate — this is what makes a pattern change tractable (grep the markers → migrate every listed file). Creates the manifest if missing.',
16
+ {
17
+ sourceDir: z
18
+ .string()
19
+ .optional()
20
+ .describe('Directory to scan recursively. Defaults to ./src in the project root.'),
21
+ manifestPath: z
22
+ .string()
23
+ .optional()
24
+ .describe('Path to design.manifest.md. Defaults to the project root.')
25
+ },
26
+ { readOnlyHint: false },
27
+ async ({ sourceDir, manifestPath }) => {
28
+ const path = manifestPath ?? getProjectManifestPath();
29
+ if (!path.endsWith('.md')) {
30
+ return {
31
+ content: [
32
+ { type: 'text' as const, text: `Refusing to write: "${path}" is not a .md file.` }
33
+ ],
34
+ isError: true
35
+ };
36
+ }
37
+ if (manifestPath && !isWithinProjectDir(manifestPath)) {
38
+ return {
39
+ content: [
40
+ {
41
+ type: 'text' as const,
42
+ text: `Refusing to write outside the project root: "${path}".`
43
+ }
44
+ ],
45
+ isError: true
46
+ };
47
+ }
48
+ const src = sourceDir ?? getProjectSourceDir();
49
+
50
+ // Files in the manifest are relative to the project root (the manifest's directory).
51
+ const usages = await scanMarkers(src, dirname(path));
52
+
53
+ let content: string;
54
+ let created = false;
55
+ try {
56
+ content = await readFile(path, 'utf-8');
57
+ } catch {
58
+ content = createManifestTemplate({});
59
+ created = true;
60
+ }
61
+
62
+ const updated = upsertUsagesSection(content, usages);
63
+ try {
64
+ await writeFile(path, updated, 'utf-8');
65
+ } catch (err) {
66
+ return {
67
+ content: [
68
+ { type: 'text' as const, text: `Failed to write ${path}: ${(err as Error).message}` }
69
+ ],
70
+ isError: true
71
+ };
72
+ }
73
+
74
+ const byPattern = new Map<string, number>();
75
+ for (const u of usages) byPattern.set(u.pattern, (byPattern.get(u.pattern) ?? 0) + 1);
76
+
77
+ let text = `Synced \`${path}\`${created ? ' (created it)' : ''} — scanned \`${src}\`, found ${usages.length} marker(s)`;
78
+ if (byPattern.size > 0) {
79
+ const summary = [...byPattern]
80
+ .sort((a, b) => a[0].localeCompare(b[0]))
81
+ .map(([p, n]) => `${p} (${n})`)
82
+ .join(', ');
83
+ text += ` across ${byPattern.size} pattern(s): ${summary}.`;
84
+ } else {
85
+ text +=
86
+ '. No markers yet — add `data-design-pattern="<name>"` to the root element of pages that follow a composition pattern.';
87
+ }
88
+
89
+ return { content: [{ type: 'text' as const, text }] };
90
+ }
91
+ );
92
+ }
@@ -0,0 +1,84 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { Finding, LintReport, Severity } from '../design-linter/index.js';
4
+ import { lintDesign } from '../design-linter/index.js';
5
+
6
+ const SEVERITY_LABEL: Record<Severity, string> = {
7
+ error: '🔴 Errors',
8
+ warning: '🟠 Warnings',
9
+ info: '🔵 Heuristics'
10
+ };
11
+
12
+ function renderFindings(findings: Finding[], severity: Severity): string {
13
+ const group = findings.filter((f) => f.severity === severity);
14
+ if (group.length === 0) return '';
15
+
16
+ let md = `### ${SEVERITY_LABEL[severity]} (${group.length})\n\n`;
17
+ for (const f of group) {
18
+ const loc = f.line ? `L${f.line}` : '—';
19
+ const where = f.match ? ` \`${f.match}\`` : '';
20
+ md += `- **[${f.ruleId}]** ${loc}${where} — ${f.message}\n`;
21
+ md += ` ↳ ${f.fix}\n`;
22
+ }
23
+ return `${md}\n`;
24
+ }
25
+
26
+ function renderReport(report: LintReport): string {
27
+ const { score, counts, findings, filename } = report;
28
+ const hardFails = counts.error + counts.warning;
29
+ const verdict = hardFails === 0 ? '✅ PASS' : '❌ NEEDS FIXES';
30
+
31
+ let md = `# Design Validation — ${verdict}\n\n`;
32
+ if (filename) md += `> \`${filename}\`\n\n`;
33
+ md += `**Score: ${score}/100** · ${counts.error} error(s), ${counts.warning} warning(s), ${counts.info} heuristic note(s)\n\n`;
34
+
35
+ if (findings.length === 0) {
36
+ md +=
37
+ 'No issues found. Tokens are valid, no `dark:`/`focus:`/hardcoded z-index, and the distribution heuristics are satisfied.\n';
38
+ return md;
39
+ }
40
+
41
+ if (counts.error > 0) {
42
+ md +=
43
+ 'Errors are deterministic defects (broken output or token-system bypass) — fix all of them before shipping.\n\n';
44
+ }
45
+ md += renderFindings(findings, 'error');
46
+ md += renderFindings(findings, 'warning');
47
+ md += renderFindings(findings, 'info');
48
+
49
+ md += '---\n\n**Next steps:**\n';
50
+ md += '- `get_css_reference()` — exact valid token names (fixes hallucinated tokens)\n';
51
+ md +=
52
+ '- `get_design_principles(as="rubric")` — score the design qualitatively after the linter passes\n';
53
+ md += '- Re-run `validate_design` after fixing to confirm.\n';
54
+ return md;
55
+ }
56
+
57
+ export function registerValidateDesignTool(server: McpServer): void {
58
+ server.tool(
59
+ 'validate_design',
60
+ 'Lint generated Svelte/HTML markup against the Urbicon UI design rules. Deterministic checks (raw Tailwind colours, `dark:`/`focus:` misuse, hardcoded z-index, broken dynamic classes, hallucinated tokens) plus distribution heuristics (intent-colour rainbow, uniform spacing, identical Cards, missing radius strategy). Returns a 0–100 score (each error −10, warning −5, heuristic −2, floored at 0) and per-finding fixes. Run this in a generate → validate → fix loop after producing UI code.',
61
+ {
62
+ code: z
63
+ .string()
64
+ .describe(
65
+ 'The Svelte/HTML/JSX source to validate (the markup of a generated page or component).'
66
+ ),
67
+ filename: z
68
+ .string()
69
+ .optional()
70
+ .describe('Optional label echoed back in the report (e.g. "dashboard/+page.svelte").'),
71
+ skipHeuristics: z
72
+ .boolean()
73
+ .optional()
74
+ .describe(
75
+ 'Skip the advisory distribution heuristics; report only deterministic violations. Default: false.'
76
+ )
77
+ },
78
+ { readOnlyHint: true },
79
+ async ({ code, filename, skipHeuristics }) => {
80
+ const report = lintDesign(code, { filename, skipHeuristics });
81
+ return { content: [{ type: 'text' as const, text: renderReport(report) }] };
82
+ }
83
+ );
84
+ }
@@ -0,0 +1,79 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import { createServer as createMcpServer } from '../server.js';
4
+
5
+ export async function startHttpTransport(port: number): Promise<void> {
6
+ const sessions = new Map<string, StreamableHTTPServerTransport>();
7
+
8
+ const httpServer = createHttpServer(async (req, res) => {
9
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
10
+
11
+ if (url.pathname !== '/mcp') {
12
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
13
+ res.end('Urbicon UI MCP Server');
14
+ return;
15
+ }
16
+
17
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
18
+
19
+ if (req.method === 'POST') {
20
+ if (sessionId && sessions.has(sessionId)) {
21
+ await sessions.get(sessionId)!.handleRequest(req, res);
22
+ } else if (!sessionId) {
23
+ const transport = new StreamableHTTPServerTransport({
24
+ sessionIdGenerator: () => crypto.randomUUID()
25
+ });
26
+ transport.onclose = () => {
27
+ if (transport.sessionId) sessions.delete(transport.sessionId);
28
+ };
29
+ const server = createMcpServer();
30
+ await server.connect(transport);
31
+ await transport.handleRequest(req, res);
32
+ if (transport.sessionId) sessions.set(transport.sessionId, transport);
33
+ } else {
34
+ res.writeHead(400, { 'Content-Type': 'application/json' });
35
+ res.end(
36
+ JSON.stringify({
37
+ jsonrpc: '2.0',
38
+ error: { code: -32600, message: 'Invalid session' },
39
+ id: null
40
+ })
41
+ );
42
+ }
43
+ } else if (req.method === 'GET') {
44
+ if (sessionId && sessions.has(sessionId)) {
45
+ await sessions.get(sessionId)!.handleRequest(req, res);
46
+ } else {
47
+ res.writeHead(400, { 'Content-Type': 'application/json' });
48
+ res.end(
49
+ JSON.stringify({
50
+ jsonrpc: '2.0',
51
+ error: { code: -32600, message: 'Missing or invalid session' },
52
+ id: null
53
+ })
54
+ );
55
+ }
56
+ } else if (req.method === 'DELETE') {
57
+ if (sessionId && sessions.has(sessionId)) {
58
+ await sessions.get(sessionId)!.handleRequest(req, res);
59
+ sessions.delete(sessionId);
60
+ } else {
61
+ res.writeHead(204);
62
+ res.end();
63
+ }
64
+ } else {
65
+ res.writeHead(405, { 'Content-Type': 'application/json' });
66
+ res.end(
67
+ JSON.stringify({
68
+ jsonrpc: '2.0',
69
+ error: { code: -32600, message: 'Method not allowed' },
70
+ id: null
71
+ })
72
+ );
73
+ }
74
+ });
75
+
76
+ httpServer.listen(port, () => {
77
+ console.error(`Urbicon UI MCP Server listening on http://localhost:${port}/mcp`);
78
+ });
79
+ }
@@ -0,0 +1,7 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+
4
+ export async function startStdioTransport(server: McpServer): Promise<void> {
5
+ const transport = new StdioServerTransport();
6
+ await server.connect(transport);
7
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ComponentCatalogEntry, RecipeEntry } from '../data/catalog-loader.js';
3
+ import { filterInternalComponents, formatCompactCatalog } from './format-catalog.js';
4
+
5
+ function makeEntry(
6
+ overrides: Partial<ComponentCatalogEntry> & Pick<ComponentCatalogEntry, 'name' | 'slug'>
7
+ ): ComponentCatalogEntry {
8
+ return {
9
+ package: '@urbicon-ui/blocks',
10
+ group: 'primitives',
11
+ description: 'desc',
12
+ tags: [],
13
+ import: `import { ${overrides.name} } from '@urbicon-ui/blocks';`,
14
+ llmTxtPath: '',
15
+ variants: [],
16
+ keyProps: [],
17
+ keyPropTypes: {},
18
+ slots: [],
19
+ hasExamples: false,
20
+ relatedComponents: [],
21
+ ...overrides
22
+ };
23
+ }
24
+
25
+ describe('filterInternalComponents', () => {
26
+ it('removes docs-site-internal components from the list', () => {
27
+ const entries = [
28
+ makeEntry({ name: 'Button', slug: 'button' }),
29
+ makeEntry({ name: 'ApiReference', slug: 'api-reference' }),
30
+ makeEntry({ name: 'PlaygroundConfigurator', slug: 'playground-configurator' }),
31
+ makeEntry({ name: 'Input', slug: 'input' })
32
+ ];
33
+ const result = filterInternalComponents(entries);
34
+ expect(result.map((e) => e.name)).toEqual(['Button', 'Input']);
35
+ });
36
+
37
+ it('returns an empty list when all entries are internal', () => {
38
+ const entries = [
39
+ makeEntry({ name: 'DocsLayout', slug: 'docs-layout' }),
40
+ makeEntry({ name: 'InfoCard', slug: 'info-card' })
41
+ ];
42
+ expect(filterInternalComponents(entries)).toEqual([]);
43
+ });
44
+ });
45
+
46
+ describe('formatCompactCatalog', () => {
47
+ const button = makeEntry({
48
+ name: 'Button',
49
+ slug: 'button',
50
+ description: 'Click to do things',
51
+ tags: ['action'],
52
+ variants: [{ name: 'intent', values: ['primary', 'danger'] }]
53
+ });
54
+ const input = makeEntry({
55
+ name: 'Input',
56
+ slug: 'input',
57
+ description: 'Text field',
58
+ tags: ['form'],
59
+ relatedComponents: ['Textarea']
60
+ });
61
+ const alert = makeEntry({
62
+ name: 'Alert',
63
+ slug: 'alert',
64
+ description: 'Inline message',
65
+ tags: ['feedback']
66
+ });
67
+ const orphan = makeEntry({
68
+ name: 'Mystery',
69
+ slug: 'mystery',
70
+ description: 'No primary tag here',
71
+ tags: ['unknown']
72
+ });
73
+
74
+ it('outputs a markdown document with the component count', () => {
75
+ const md = formatCompactCatalog([button, input]);
76
+ expect(md).toContain('# Urbicon UI Components');
77
+ expect(md).toContain('> 2 components available.');
78
+ expect(md).toContain('get_component');
79
+ });
80
+
81
+ it('groups components by their primary tag in the canonical order', () => {
82
+ const md = formatCompactCatalog([alert, button, input]);
83
+ const actionIdx = md.indexOf('## Actions');
84
+ const formsIdx = md.indexOf('## Forms');
85
+ const feedbackIdx = md.indexOf('## Feedback & Status');
86
+ expect(actionIdx).toBeGreaterThan(-1);
87
+ expect(formsIdx).toBeGreaterThan(actionIdx);
88
+ expect(feedbackIdx).toBeGreaterThan(formsIdx);
89
+ });
90
+
91
+ it('puts components with unrecognised primary tags in the Other bucket', () => {
92
+ const md = formatCompactCatalog([orphan]);
93
+ expect(md).toContain('## Other');
94
+ expect(md).toContain('Mystery');
95
+ });
96
+
97
+ it('renders variant details when a non-boolean variant is present', () => {
98
+ const md = formatCompactCatalog([button]);
99
+ expect(md).toContain('intent: primary/danger');
100
+ });
101
+
102
+ it('omits boolean-only variants from the rendered line', () => {
103
+ const withBool = makeEntry({
104
+ name: 'Toggle',
105
+ slug: 'toggle',
106
+ description: 'On/off',
107
+ tags: ['form'],
108
+ variants: [{ name: 'checked', values: ['true', 'false'] }]
109
+ });
110
+ const md = formatCompactCatalog([withBool]);
111
+ expect(md).not.toContain('checked: true/false');
112
+ });
113
+
114
+ it('renders a Related hint when related components exist', () => {
115
+ const md = formatCompactCatalog([input]);
116
+ expect(md).toContain('Related: Textarea');
117
+ });
118
+
119
+ it('filters by the tags option', () => {
120
+ const md = formatCompactCatalog([button, input, alert], { tags: ['form'] });
121
+ // Matching the bold-component-bullet prefix avoids false positives from
122
+ // the Quick-Setup section (which itself references `Button` in code).
123
+ expect(md).toContain('- **Input**');
124
+ expect(md).not.toContain('- **Button**');
125
+ expect(md).not.toContain('- **Alert**');
126
+ });
127
+
128
+ it('appends a Recipes section when recipes are supplied', () => {
129
+ const recipes: RecipeEntry[] = [
130
+ {
131
+ id: 'login-form',
132
+ title: 'Login',
133
+ description: 'Login screen',
134
+ components: ['Input', 'Button'],
135
+ code: '',
136
+ features: []
137
+ }
138
+ ];
139
+ const md = formatCompactCatalog([button, input], { recipes });
140
+ expect(md).toContain('## Recipes');
141
+ expect(md).toContain('**login-form**');
142
+ expect(md).toContain('Input, Button');
143
+ });
144
+ });
@@ -0,0 +1,130 @@
1
+ import type { ComponentCatalogEntry, RecipeEntry } from '../data/catalog-loader.js';
2
+
3
+ const INTERNAL_COMPONENTS = new Set([
4
+ 'ApiReference',
5
+ 'CodeExample',
6
+ 'DocsLayout',
7
+ 'InfoCard',
8
+ 'PlaygroundConfigurator',
9
+ 'Section',
10
+ 'TableOfContents',
11
+ 'TypesReference'
12
+ ]);
13
+
14
+ const TAG_ORDER = ['action', 'form', 'layout', 'feedback', 'navigation', 'display', 'data'];
15
+
16
+ const TAG_LABELS: Record<string, string> = {
17
+ action: 'Actions',
18
+ form: 'Forms',
19
+ layout: 'Layout & Containers',
20
+ feedback: 'Feedback & Status',
21
+ navigation: 'Navigation',
22
+ display: 'Display & Overlays',
23
+ data: 'Data'
24
+ };
25
+
26
+ export function filterInternalComponents(
27
+ components: ComponentCatalogEntry[]
28
+ ): ComponentCatalogEntry[] {
29
+ return components.filter((c) => !INTERNAL_COMPONENTS.has(c.name));
30
+ }
31
+
32
+ export function formatCompactCatalog(
33
+ components: ComponentCatalogEntry[],
34
+ options?: { recipes?: RecipeEntry[]; tags?: string[] }
35
+ ): string {
36
+ let filtered = filterInternalComponents(components);
37
+
38
+ if (options?.tags && options.tags.length > 0) {
39
+ const tagSet = new Set(options.tags.map((t) => t.toLowerCase()));
40
+ filtered = filtered.filter((c) => c.tags.some((t) => tagSet.has(t.toLowerCase())));
41
+ }
42
+
43
+ const grouped = new Map<string, ComponentCatalogEntry[]>();
44
+ const ungrouped: ComponentCatalogEntry[] = [];
45
+
46
+ for (const comp of filtered) {
47
+ const primaryTag = comp.tags[0];
48
+ if (primaryTag && TAG_LABELS[primaryTag]) {
49
+ if (!grouped.has(primaryTag)) grouped.set(primaryTag, []);
50
+ grouped.get(primaryTag)!.push(comp);
51
+ } else {
52
+ ungrouped.push(comp);
53
+ }
54
+ }
55
+
56
+ let md = '# Urbicon UI Components\n\n';
57
+ md += `> ${filtered.length} components available.\n`;
58
+ md += '> For full API docs on any component: use `get_component` tool.\n\n';
59
+
60
+ md += '## Quick Setup\n\n';
61
+ md += 'Install from npm:\n';
62
+ md += '```bash\nbun add @urbicon-ui/blocks @urbicon-ui/i18n\n```\n';
63
+ md +=
64
+ 'CSS setup (root layout or entry CSS) — your app owns the Tailwind import and it comes first:\n';
65
+ md += '```css\n';
66
+ md += "@import 'tailwindcss';\n";
67
+ md +=
68
+ "@import '@urbicon-ui/blocks/style/index.css'; /* tokens + @source directives — import this, NOT the foundation/semantic/interaction subfiles, and add no manual @source */\n";
69
+ md += '```\n';
70
+ md += "Import: `import { Button, Card } from '@urbicon-ui/blocks';` (always from package root)\n";
71
+ md +=
72
+ 'Dark mode: automatic via `prefers-color-scheme` — semantic tokens switch automatically. For manual control, use `ThemeSwitcher` or `data-theme="dark"` on `<html>`.\n\n';
73
+
74
+ for (const tag of TAG_ORDER) {
75
+ const comps = grouped.get(tag);
76
+ if (!comps || comps.length === 0) continue;
77
+
78
+ md += `## ${TAG_LABELS[tag]}\n`;
79
+ for (const comp of comps) {
80
+ md += formatComponentLine(comp);
81
+ }
82
+ md += '\n';
83
+ }
84
+
85
+ if (ungrouped.length > 0) {
86
+ md += '## Other\n';
87
+ for (const comp of ungrouped) {
88
+ md += formatComponentLine(comp);
89
+ }
90
+ md += '\n';
91
+ }
92
+
93
+ if (options?.recipes && options.recipes.length > 0) {
94
+ md += '## Recipes\n';
95
+ md += '> Full production-ready code examples. Use `get_recipe` tool with the recipe id.\n';
96
+ for (const recipe of options.recipes) {
97
+ md += `- **${recipe.id}** — ${recipe.description} (${recipe.components.join(', ')})\n`;
98
+ }
99
+ md += '\n';
100
+ }
101
+
102
+ return md;
103
+ }
104
+
105
+ function isBooleanVariant(values: string[]): boolean {
106
+ const sorted = [...values].sort();
107
+ return (
108
+ (sorted.length === 1 && (sorted[0] === 'true' || sorted[0] === 'false')) ||
109
+ (sorted.length === 2 && sorted[0] === 'false' && sorted[1] === 'true')
110
+ );
111
+ }
112
+
113
+ function formatComponentLine(comp: ComponentCatalogEntry): string {
114
+ const variants = comp.variants
115
+ .filter((v) => !isBooleanVariant(v.values))
116
+ .map((v) => `${v.name}: ${v.values.join('/')}`)
117
+ .join(' · ');
118
+
119
+ const related =
120
+ comp.relatedComponents.length > 0 ? ` | Related: ${comp.relatedComponents.join(', ')}` : '';
121
+
122
+ const pkg = comp.package !== '@urbicon-ui/blocks' ? ` _(from \`${comp.package}\`)_` : '';
123
+
124
+ let line = `- **${comp.name}**${pkg} — ${comp.description}`;
125
+ if (variants) line += ` | ${variants}`;
126
+ line += related;
127
+ line += '\n';
128
+
129
+ return line;
130
+ }
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import {
3
+ getCatalogPath,
4
+ getComponentLlmPath,
5
+ getDataDir,
6
+ getRecipeDir,
7
+ getTemplateDir,
8
+ getTemplatePath,
9
+ isWithinProjectDir
10
+ } from './paths.js';
11
+
12
+ describe('paths', () => {
13
+ const originalDataDir = process.env.DATA_DIR;
14
+
15
+ afterEach(() => {
16
+ if (originalDataDir === undefined) {
17
+ delete process.env.DATA_DIR;
18
+ } else {
19
+ process.env.DATA_DIR = originalDataDir;
20
+ }
21
+ });
22
+
23
+ describe('isWithinProjectDir', () => {
24
+ const original = process.env.DESIGN_PROJECT_DIR;
25
+ beforeEach(() => {
26
+ process.env.DESIGN_PROJECT_DIR = '/home/user/app';
27
+ });
28
+ afterEach(() => {
29
+ if (original === undefined) delete process.env.DESIGN_PROJECT_DIR;
30
+ else process.env.DESIGN_PROJECT_DIR = original;
31
+ });
32
+
33
+ it('accepts paths inside the project root', () => {
34
+ expect(isWithinProjectDir('/home/user/app/design.manifest.md')).toBe(true);
35
+ expect(isWithinProjectDir('/home/user/app/docs/design.manifest.md')).toBe(true);
36
+ });
37
+ it('rejects paths outside the project root', () => {
38
+ expect(isWithinProjectDir('/etc/motd.md')).toBe(false);
39
+ expect(isWithinProjectDir('/home/user/app/../other/x.md')).toBe(false);
40
+ expect(isWithinProjectDir('/home/user/application/x.md')).toBe(false); // prefix-but-not-child
41
+ });
42
+ });
43
+
44
+ describe('getDataDir', () => {
45
+ beforeEach(() => {
46
+ delete process.env.DATA_DIR;
47
+ });
48
+
49
+ it('defaults to apps/docs/static relative to the package', () => {
50
+ const dir = getDataDir();
51
+ expect(dir.endsWith('/apps/docs/static')).toBe(true);
52
+ });
53
+
54
+ it('honours DATA_DIR env override', () => {
55
+ process.env.DATA_DIR = '/custom/data/dir';
56
+ expect(getDataDir()).toBe('/custom/data/dir');
57
+ });
58
+ });
59
+
60
+ describe('getTemplateDir / getRecipeDir / getTemplatePath / getCatalogPath', () => {
61
+ it('returns absolute paths rooted in the monorepo', () => {
62
+ expect(getTemplateDir()).toMatch(/\/packages\/docs-gen\/templates$/);
63
+ expect(getRecipeDir()).toMatch(/\/apps\/docs\/src\/routes\/recipes$/);
64
+ expect(getTemplatePath()).toMatch(/\/packages\/docs-gen\/templates\/llms-full-template\.md$/);
65
+ expect(getCatalogPath()).toMatch(/\/mcp\/component-catalog\.json$/);
66
+ });
67
+ });
68
+
69
+ describe('getComponentLlmPath', () => {
70
+ it('resolves a valid slug to a path inside the data dir', () => {
71
+ const p = getComponentLlmPath('primitives', 'button');
72
+ expect(p).toMatch(/\/primitives\/button\/llm\.txt$/);
73
+ });
74
+
75
+ it('accepts multi-word hyphenated slugs', () => {
76
+ const p = getComponentLlmPath('components', 'date-picker');
77
+ expect(p).toMatch(/\/components\/date-picker\/llm\.txt$/);
78
+ });
79
+
80
+ it('rejects uppercase in the slug', () => {
81
+ expect(() => getComponentLlmPath('primitives', 'Button')).toThrow(/Invalid component slug/);
82
+ });
83
+
84
+ it('rejects whitespace', () => {
85
+ expect(() => getComponentLlmPath('primitives', 'bad slug')).toThrow(/Invalid component slug/);
86
+ });
87
+
88
+ it('rejects leading or trailing hyphens', () => {
89
+ expect(() => getComponentLlmPath('primitives', '-bad')).toThrow(/Invalid component slug/);
90
+ expect(() => getComponentLlmPath('primitives', 'bad-')).toThrow(/Invalid component slug/);
91
+ });
92
+
93
+ it('rejects path traversal attempts', () => {
94
+ // Any dotted segment fails the SAFE_SLUG regex first.
95
+ expect(() => getComponentLlmPath('primitives', '..')).toThrow(/Invalid component slug/);
96
+ expect(() => getComponentLlmPath('primitives', '../../../etc/passwd')).toThrow(
97
+ /Invalid component slug/
98
+ );
99
+ });
100
+ });
101
+ });