@santi020k/lumen-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/dist/tools.js ADDED
@@ -0,0 +1,346 @@
1
+ import { loadLumenData } from './data.js';
2
+ const normalize = (value) => value.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2').replaceAll(/[\s_]+/g, '-').toLowerCase();
3
+ const tokenize = (value) => normalize(value).split(/[^a-z0-9]+/).filter(Boolean);
4
+ const availableFrameworks = (component) => Object.entries(component.frameworks)
5
+ .filter(([, available]) => available)
6
+ .map(([framework]) => framework);
7
+ const componentSummary = (component) => ({
8
+ category: component.category,
9
+ collections: component.collections,
10
+ description: component.description,
11
+ frameworks: availableFrameworks(component),
12
+ kebab: component.kebab,
13
+ name: component.name,
14
+ recipes: component.recipes.map((recipe) => recipe.name)
15
+ });
16
+ export const resolveComponent = (name, data = loadLumenData()) => {
17
+ const target = normalize(name);
18
+ return data.components.find((component) => component.name === name) ??
19
+ data.components.find((component) => component.kebab === target) ??
20
+ data.components.find((component) => normalize(component.name) === target);
21
+ };
22
+ export const resolveRecipe = (name, data = loadLumenData()) => {
23
+ const target = normalize(name);
24
+ return data.recipes.find((recipe) => normalize(recipe.name) === target);
25
+ };
26
+ const frameworkSearchText = (component) => Object.values(component.frameworkDetails)
27
+ .flatMap((framework) => [
28
+ framework.packageName,
29
+ framework.propsExtends ?? '',
30
+ framework.props.map((prop) => `${prop.name} ${prop.type}`).join(' '),
31
+ framework.attributes?.join(' ') ?? '',
32
+ framework.tagName ?? ''
33
+ ])
34
+ .join(' ');
35
+ const componentSearchText = (component) => [
36
+ component.name,
37
+ component.kebab,
38
+ 'accessible accessibility',
39
+ component.description,
40
+ component.category,
41
+ component.collections.join(' '),
42
+ component.keywords.join(' '),
43
+ component.dependencies.join(' '),
44
+ component.guidance?.when ?? '',
45
+ component.guidance?.distinction ?? '',
46
+ component.apiReference
47
+ .map((row) => `${row.attribute} ${row.values} ${row.description}`)
48
+ .join(' '),
49
+ frameworkSearchText(component)
50
+ ].join(' ');
51
+ const scoreCandidate = (query, terms, searchableText, primaryName = '') => {
52
+ const normalizedText = normalize(searchableText);
53
+ const matchedTerms = terms.filter((term) => normalizedText.includes(term));
54
+ if (matchedTerms.length !== terms.length)
55
+ return { matchedTerms, score: 0 };
56
+ const normalizedQuery = normalize(query);
57
+ const normalizedName = normalize(primaryName);
58
+ let score = terms.reduce((total, term) => total + (normalizedName.includes(term) ? 20 : 5), 0);
59
+ if (normalizedText.includes(normalizedQuery))
60
+ score += 100;
61
+ if (normalizedName === normalizedQuery)
62
+ score += 500;
63
+ return { matchedTerms, score };
64
+ };
65
+ const appendSearchResult = (results, candidate, query, terms, searchableText) => {
66
+ const match = scoreCandidate(query, terms, searchableText, candidate.name);
67
+ if (match.score > 0)
68
+ results.push({ ...candidate, ...match });
69
+ };
70
+ const collectSearchResults = (query, terms, data) => {
71
+ const results = [];
72
+ for (const component of data.components) {
73
+ appendSearchResult(results, {
74
+ category: component.category,
75
+ description: component.description,
76
+ frameworks: availableFrameworks(component),
77
+ kind: 'component',
78
+ name: component.name
79
+ }, query, terms, componentSearchText(component));
80
+ }
81
+ for (const recipe of data.recipes) {
82
+ appendSearchResult(results, {
83
+ category: recipe.categories.join(', '),
84
+ description: recipe.description,
85
+ kind: 'recipe',
86
+ name: recipe.name
87
+ }, query, terms, `${recipe.name} ${recipe.description} ${recipe.categories.join(' ')} ${(recipe.components ?? []).join(' ')}`);
88
+ }
89
+ for (const token of data.tokens.semantic) {
90
+ appendSearchResult(results, {
91
+ description: 'Lumen semantic design token.',
92
+ kind: 'token',
93
+ name: token
94
+ }, query, terms, token);
95
+ }
96
+ for (const line of data.rules.split('\n').filter((rule) => rule.trim())) {
97
+ appendSearchResult(results, {
98
+ description: line.trim(),
99
+ kind: 'rule',
100
+ name: line.trim()
101
+ }, query, terms, line);
102
+ }
103
+ return results;
104
+ };
105
+ export const listComponents = (args = {}, data = loadLumenData()) => {
106
+ const query = args.query?.trim();
107
+ const queryTerms = query ? tokenize(query) : [];
108
+ const matches = data.components.filter((component) => {
109
+ if (args.framework && !component.frameworks[args.framework])
110
+ return false;
111
+ if (args.recipe && !component.recipes.some((recipe) => recipe.name === args.recipe))
112
+ return false;
113
+ if (query &&
114
+ scoreCandidate(query, queryTerms, componentSearchText(component), component.name).score === 0)
115
+ return false;
116
+ return true;
117
+ });
118
+ const summaries = matches.map(componentSummary);
119
+ if (summaries.length === 0) {
120
+ return {
121
+ data: { components: [], count: 0 },
122
+ text: 'No components matched the given filters.'
123
+ };
124
+ }
125
+ const lines = summaries.map((component) => {
126
+ const recipeNote = component.recipes.length > 0
127
+ ? ` [recipes: ${component.recipes.join(', ')}]`
128
+ : '';
129
+ return `${component.name} (${component.kebab}) — ${component.description} — ${component.frameworks.join(', ')}${recipeNote}`;
130
+ });
131
+ return {
132
+ data: { components: summaries, count: summaries.length },
133
+ text: `${summaries.length} component(s):\n\n${lines.join('\n')}`
134
+ };
135
+ };
136
+ const frameworkWithoutSource = (framework) => {
137
+ const { source: _source, ...usage } = framework;
138
+ return usage;
139
+ };
140
+ const getFrameworkDetail = (details, framework) => {
141
+ if (framework === 'astro')
142
+ return details.astro;
143
+ if (framework === 'elements')
144
+ return details.elements;
145
+ return details.react;
146
+ };
147
+ const getRecipeInstall = (install, framework) => {
148
+ if (framework === 'astro')
149
+ return install.astro;
150
+ if (framework === 'elements')
151
+ return install.elements;
152
+ return install.react;
153
+ };
154
+ const componentDetail = (component, framework, detail) => {
155
+ const selectedFramework = getFrameworkDetail(component.frameworkDetails, framework);
156
+ const summary = componentSummary(component);
157
+ if (detail === 'summary') {
158
+ return {
159
+ ...summary,
160
+ dependencies: component.dependencies,
161
+ framework: {
162
+ available: selectedFramework.available,
163
+ packageName: selectedFramework.packageName,
164
+ tagName: selectedFramework.tagName
165
+ },
166
+ guidance: component.guidance
167
+ };
168
+ }
169
+ const usage = {
170
+ ...summary,
171
+ apiReference: component.apiReference,
172
+ dependencies: component.dependencies,
173
+ files: component.files,
174
+ framework: frameworkWithoutSource(selectedFramework),
175
+ guidance: component.guidance,
176
+ keyboardInteractions: component.keyboardInteractions,
177
+ runtimeEvents: component.runtimeEvents
178
+ };
179
+ return detail === 'source'
180
+ ? { ...usage, framework: selectedFramework }
181
+ : usage;
182
+ };
183
+ const formatProps = (framework) => {
184
+ if (framework.props.length === 0) {
185
+ return framework.propsExtends
186
+ ? `Props extend ${framework.propsExtends}; no additional local props were detected.`
187
+ : 'No typed props detected.';
188
+ }
189
+ const lines = framework.props.map((prop) => `- ${prop.name}${prop.optional ? '?' : ''}: ${prop.type}`);
190
+ const header = framework.propsExtends
191
+ ? `Props (extends ${framework.propsExtends}):`
192
+ : 'Props:';
193
+ return `${header}\n${lines.join('\n')}`;
194
+ };
195
+ const formatComponentUsage = (component, frameworkName, detail) => {
196
+ const framework = getFrameworkDetail(component.frameworkDetails, frameworkName);
197
+ const sections = [
198
+ `# ${component.name}`,
199
+ component.description,
200
+ `Category: ${component.category}`,
201
+ `Framework: ${frameworkName}`,
202
+ `Package: ${framework.packageName}`,
203
+ `Import: ${framework.importStatement}`,
204
+ `Styles: ${framework.styleImport}`,
205
+ framework.tagName ? `Custom element: <${framework.tagName}>` : '',
206
+ component.recipes.length > 0
207
+ ? `Recipes: ${component.recipes.map((recipe) => recipe.name).join(', ')}`
208
+ : 'Recipes: none'
209
+ ].filter(Boolean);
210
+ if (detail === 'summary')
211
+ return sections.join('\n');
212
+ sections.push('', formatProps(framework), '', '## Example', `\`\`\`${framework.language}`);
213
+ sections.push(framework.example, '```');
214
+ if (component.guidance) {
215
+ sections.push('', '## Guidance', component.guidance.when);
216
+ if (component.guidance.distinction)
217
+ sections.push(component.guidance.distinction);
218
+ }
219
+ if (component.keyboardInteractions.length > 0) {
220
+ sections.push('', '## Keyboard interactions');
221
+ sections.push(...component.keyboardInteractions.map((row) => `- ${row.key}: ${row.action}`));
222
+ }
223
+ if (component.runtimeEvents.length > 0) {
224
+ sections.push('', '## Runtime events');
225
+ sections.push(...component.runtimeEvents.map((event) => `- ${event.name}: ${event.when}`));
226
+ }
227
+ if (detail === 'source') {
228
+ sections.push('', `## ${frameworkName} reference source`);
229
+ sections.push(`\`\`\`${framework.language}`, framework.source.trimEnd(), '```');
230
+ }
231
+ return sections.join('\n');
232
+ };
233
+ export const getComponent = (args, data = loadLumenData()) => {
234
+ const component = resolveComponent(args.name, data);
235
+ if (!component) {
236
+ const message = `Unknown component "${args.name}". Use lumen_list_components to see available names.`;
237
+ return {
238
+ data: { found: false, message },
239
+ isError: true,
240
+ text: message
241
+ };
242
+ }
243
+ const framework = args.framework ?? 'astro';
244
+ const detail = args.detail ?? (args.includeSource ? 'source' : 'usage');
245
+ const selectedFramework = getFrameworkDetail(component.frameworkDetails, framework);
246
+ if (!selectedFramework.available) {
247
+ const message = `${component.name} is not available for ${framework}.`;
248
+ return {
249
+ data: { found: false, message },
250
+ isError: true,
251
+ text: message
252
+ };
253
+ }
254
+ return {
255
+ data: {
256
+ component: componentDetail(component, framework, detail),
257
+ found: true
258
+ },
259
+ text: formatComponentUsage(component, framework, detail)
260
+ };
261
+ };
262
+ export const getRecipe = (args, data = loadLumenData()) => {
263
+ const recipe = resolveRecipe(args.name, data);
264
+ if (!recipe) {
265
+ const message = `Unknown recipe "${args.name}". Use lumen_search or lumen_list_components to discover recipes.`;
266
+ return {
267
+ data: { found: false, message },
268
+ isError: true,
269
+ text: message
270
+ };
271
+ }
272
+ const framework = args.framework ?? 'astro';
273
+ const componentLines = (recipe.components ?? []).map((name) => {
274
+ const component = resolveComponent(name, data);
275
+ return component ? `- ${component.name}: ${component.description}` : `- ${name}`;
276
+ });
277
+ return {
278
+ data: { found: true, recipe },
279
+ text: [
280
+ `# ${recipe.name}`,
281
+ recipe.description,
282
+ `Type: ${recipe.type}`,
283
+ `Categories: ${recipe.categories.join(', ') || 'all'}`,
284
+ `Install for ${framework}: ${getRecipeInstall(recipe.install, framework)}`,
285
+ '',
286
+ '## Components',
287
+ ...componentLines
288
+ ].join('\n')
289
+ };
290
+ };
291
+ export const search = (args, data = loadLumenData()) => {
292
+ const query = args.query.trim();
293
+ if (!query) {
294
+ return {
295
+ data: { query, results: [], total: 0 },
296
+ isError: true,
297
+ text: 'Provide a non-empty query.'
298
+ };
299
+ }
300
+ const limit = Math.max(1, Math.min(args.limit ?? 20, 100));
301
+ const results = collectSearchResults(query, tokenize(query), data);
302
+ results.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name));
303
+ const shown = results.slice(0, limit);
304
+ if (results.length === 0) {
305
+ return {
306
+ data: { query, results: [], total: 0 },
307
+ text: `No matches for "${args.query}".`
308
+ };
309
+ }
310
+ return {
311
+ data: { query, results: shown, total: results.length },
312
+ text: `${results.length} match(es) for "${args.query}"${results.length > shown.length ? ` (showing ${shown.length})` : ''}:\n\n${shown.map((result) => `${result.kind}: ${result.name} — ${result.description} [matched: ${result.matchedTerms.join(', ')}]`).join('\n')}`
313
+ };
314
+ };
315
+ export const getTokens = (data = loadLumenData()) => {
316
+ const { tokens } = data;
317
+ const colorLines = Object.entries(tokens.colors).map(([name, value]) => `- ${name}: hsl(${value})`);
318
+ const glassLines = Object.entries(tokens.glass).map(([name, value]) => `- ${name}: ${value}`);
319
+ const text = [
320
+ '# Lumen design tokens',
321
+ `Theme attribute: ${tokens.themeAttribute} (values: light, dark)`,
322
+ '',
323
+ '## Semantic token names (use as `text-ink`, `bg-surface`, `border-line`, etc.)',
324
+ tokens.semantic.map((token) => `- ${token}`).join('\n'),
325
+ '',
326
+ '## Base color values',
327
+ colorLines.join('\n'),
328
+ '',
329
+ '## Glass surface tokens',
330
+ glassLines.join('\n')
331
+ ].join('\n');
332
+ return { data: { tokens }, text };
333
+ };
334
+ export const getRules = (data = loadLumenData()) => {
335
+ if (!data.rules) {
336
+ return {
337
+ data: { rules: '' },
338
+ isError: true,
339
+ text: 'No agent rules (llms.txt) were bundled.'
340
+ };
341
+ }
342
+ return {
343
+ data: { rules: data.rules.trimEnd() },
344
+ text: data.rules.trimEnd()
345
+ };
346
+ };
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@santi020k/lumen-mcp",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Model Context Protocol server for the Lumen multi-framework primitive UI system. Lets AI agents list components, read real component source, and follow Lumen tokens and rules.",
8
+ "type": "module",
9
+ "sideEffects": false,
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./tools": {
19
+ "types": "./dist/tools.d.ts",
20
+ "import": "./dist/tools.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "dist",
27
+ "data",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "bin": {
32
+ "lumen-mcp": "./bin/lumen-mcp.mjs"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.29.0",
36
+ "zod": "^4.4.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^26.1.1",
40
+ "@vitest/coverage-v8": "^4.1.10",
41
+ "typescript": "^6.0.3",
42
+ "vitest": "^4.1.10"
43
+ },
44
+ "engines": {
45
+ "node": ">=22.18.0"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/santi020k/lumen.git",
50
+ "directory": "packages/mcp"
51
+ },
52
+ "homepage": "https://lumen.santi020k.com",
53
+ "bugs": {
54
+ "url": "https://github.com/santi020k/lumen/issues"
55
+ },
56
+ "keywords": [
57
+ "lumen",
58
+ "mcp",
59
+ "model-context-protocol",
60
+ "ai",
61
+ "ui",
62
+ "design-system",
63
+ "astro"
64
+ ],
65
+ "author": {
66
+ "name": "Santiago Molina",
67
+ "url": "https://santi020k.com"
68
+ },
69
+ "license": "MIT",
70
+ "scripts": {
71
+ "generate": "node scripts/generate-data.mjs",
72
+ "build": "pnpm run generate && tsc -p tsconfig.build.json",
73
+ "dev": "pnpm run generate && tsc -p tsconfig.build.json --watch --preserveWatchOutput",
74
+ "local": "node bin/lumen-mcp-local.mjs",
75
+ "typecheck": "tsc -p tsconfig.json --noEmit",
76
+ "test": "vitest run --config ../../vitest.config.ts --project mcp",
77
+ "test:coverage": "vitest run --config ../../vitest.config.ts --project mcp --coverage",
78
+ "lint": "eslint . --no-warn-ignored",
79
+ "lint:fix": "eslint . --fix --no-warn-ignored",
80
+ "clean": "rm -rf dist coverage"
81
+ }
82
+ }