@santi020k/lumen-mcp 0.1.0 → 0.3.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 CHANGED
@@ -1,6 +1,23 @@
1
1
  import { loadLumenData } from './data.js';
2
2
  const normalize = (value) => value.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2').replaceAll(/[\s_]+/g, '-').toLowerCase();
3
3
  const tokenize = (value) => normalize(value).split(/[^a-z0-9]+/).filter(Boolean);
4
+ const searchStopWords = new Set([
5
+ 'a', 'accessibility', 'accessible', 'admin', 'an', 'and', 'app', 'application', 'component', 'for',
6
+ 'in', 'lumen', 'not', 'of', 'or', 'the', 'to', 'ui', 'with'
7
+ ]);
8
+ const searchTermAliases = {
9
+ booking: ['booking', 'schedule', 'calendar', 'appointment', 'date'],
10
+ dark: ['dark', 'theme'],
11
+ dashboard: ['dashboard', 'data-table', 'sidebar', 'chart', 'stat', 'metric'],
12
+ mode: ['mode', 'theme'],
13
+ pagination: ['pagination', 'paginated'],
14
+ sortable: ['sortable', 'sort']
15
+ };
16
+ const meaningfulSearchTerms = (value) => {
17
+ const terms = tokenize(value);
18
+ const meaningful = terms.filter((term) => !searchStopWords.has(term));
19
+ return meaningful.length > 0 ? meaningful : terms;
20
+ };
4
21
  const availableFrameworks = (component) => Object.entries(component.frameworks)
5
22
  .filter(([, available]) => available)
6
23
  .map(([framework]) => framework);
@@ -23,22 +40,33 @@ export const resolveRecipe = (name, data = loadLumenData()) => {
23
40
  const target = normalize(name);
24
41
  return data.recipes.find((recipe) => normalize(recipe.name) === target);
25
42
  };
26
- const frameworkSearchText = (component) => Object.values(component.frameworkDetails)
27
- .flatMap((framework) => [
43
+ const frameworkBehaviorSearchText = (behavior) => [
44
+ behavior?.description ?? '',
45
+ behavior?.hook ?? '',
46
+ behavior?.mode ?? '',
47
+ behavior?.setup ?? ''
48
+ ];
49
+ const frameworkDetailsSearchText = (framework) => [
28
50
  framework.packageName,
51
+ ...frameworkBehaviorSearchText(framework.behavior),
52
+ framework.example,
29
53
  framework.propsExtends ?? '',
30
54
  framework.props.map((prop) => `${prop.name} ${prop.type}`).join(' '),
31
55
  framework.attributes?.join(' ') ?? '',
32
56
  framework.tagName ?? ''
33
- ])
57
+ ];
58
+ const frameworkSearchText = (component, framework) => (framework
59
+ ? [Reflect.get(component.frameworkDetails, framework)]
60
+ : Object.values(component.frameworkDetails))
61
+ .flatMap(frameworkDetailsSearchText)
34
62
  .join(' ');
35
- const componentSearchText = (component) => [
63
+ const componentSearchText = (component, framework) => [
36
64
  component.name,
37
65
  component.kebab,
38
- 'accessible accessibility',
39
66
  component.description,
40
67
  component.category,
41
68
  component.collections.join(' '),
69
+ component.recipes.map((recipe) => recipe.name).join(' '),
42
70
  component.keywords.join(' '),
43
71
  component.dependencies.join(' '),
44
72
  component.guidance?.when ?? '',
@@ -46,16 +74,23 @@ const componentSearchText = (component) => [
46
74
  component.apiReference
47
75
  .map((row) => `${row.attribute} ${row.values} ${row.description}`)
48
76
  .join(' '),
49
- frameworkSearchText(component)
77
+ frameworkSearchText(component, framework)
50
78
  ].join(' ');
51
79
  const scoreCandidate = (query, terms, searchableText, primaryName = '') => {
52
80
  const normalizedText = normalize(searchableText);
53
- const matchedTerms = terms.filter((term) => normalizedText.includes(term));
54
- if (matchedTerms.length !== terms.length)
81
+ const matchedTerms = terms.filter((term) => (Reflect.get(searchTermAliases, term) ?? [term]).some((candidate) => normalizedText.includes(candidate)));
82
+ if (matchedTerms.length === 0)
55
83
  return { matchedTerms, score: 0 };
56
- const normalizedQuery = normalize(query);
84
+ const normalizedQuery = terms.join('-');
57
85
  const normalizedName = normalize(primaryName);
58
- let score = terms.reduce((total, term) => total + (normalizedName.includes(term) ? 20 : 5), 0);
86
+ const coverage = matchedTerms.length / terms.length;
87
+ let score = matchedTerms.reduce((total, term) => total + ((Reflect.get(searchTermAliases, term) ?? [term]).some((candidate) => normalizedName.includes(candidate))
88
+ ? 20
89
+ : 5), 0);
90
+ score += Math.round(coverage * 80);
91
+ score -= (terms.length - matchedTerms.length) * 3;
92
+ if (terms.includes(normalizedName))
93
+ score += 50;
59
94
  if (normalizedText.includes(normalizedQuery))
60
95
  score += 100;
61
96
  if (normalizedName === normalizedQuery)
@@ -63,20 +98,22 @@ const scoreCandidate = (query, terms, searchableText, primaryName = '') => {
63
98
  return { matchedTerms, score };
64
99
  };
65
100
  const appendSearchResult = (results, candidate, query, terms, searchableText) => {
66
- const match = scoreCandidate(query, terms, searchableText, candidate.name);
101
+ const match = scoreCandidate(query, terms, searchableText, candidate.kind === 'rule' ? '' : candidate.name);
67
102
  if (match.score > 0)
68
103
  results.push({ ...candidate, ...match });
69
104
  };
70
- const collectSearchResults = (query, terms, data) => {
105
+ const collectSearchResults = (query, terms, data, framework) => {
71
106
  const results = [];
72
107
  for (const component of data.components) {
108
+ if (framework && !Reflect.get(component.frameworks, framework))
109
+ continue;
73
110
  appendSearchResult(results, {
74
111
  category: component.category,
75
112
  description: component.description,
76
113
  frameworks: availableFrameworks(component),
77
114
  kind: 'component',
78
115
  name: component.name
79
- }, query, terms, componentSearchText(component));
116
+ }, query, terms, componentSearchText(component, framework));
80
117
  }
81
118
  for (const recipe of data.recipes) {
82
119
  appendSearchResult(results, {
@@ -104,14 +141,17 @@ const collectSearchResults = (query, terms, data) => {
104
141
  };
105
142
  export const listComponents = (args = {}, data = loadLumenData()) => {
106
143
  const query = args.query?.trim();
107
- const queryTerms = query ? tokenize(query) : [];
144
+ const queryTerms = query ? meaningfulSearchTerms(query) : [];
145
+ const recipe = args.recipe ? resolveRecipe(args.recipe, data) : undefined;
146
+ const recipeName = recipe?.name ?? (args.recipe ? normalize(args.recipe) : undefined);
108
147
  const matches = data.components.filter((component) => {
109
148
  if (args.framework && !component.frameworks[args.framework])
110
149
  return false;
111
- if (args.recipe && !component.recipes.some((recipe) => recipe.name === args.recipe))
150
+ if (recipeName &&
151
+ !component.recipes.some((componentRecipe) => normalize(componentRecipe.name) === recipeName))
112
152
  return false;
113
153
  if (query &&
114
- scoreCandidate(query, queryTerms, componentSearchText(component), component.name).score === 0)
154
+ scoreCandidate(query, queryTerms, componentSearchText(component, args.framework), component.name).score === 0)
115
155
  return false;
116
156
  return true;
117
157
  });
@@ -181,6 +221,9 @@ const componentDetail = (component, framework, detail) => {
181
221
  : usage;
182
222
  };
183
223
  const formatProps = (framework) => {
224
+ if (framework.attributes && framework.attributes.length > 0) {
225
+ return `Attributes:\n${framework.attributes.map((attribute) => `- ${attribute}`).join('\n')}`;
226
+ }
184
227
  if (framework.props.length === 0) {
185
228
  return framework.propsExtends
186
229
  ? `Props extend ${framework.propsExtends}; no additional local props were detected.`
@@ -192,25 +235,35 @@ const formatProps = (framework) => {
192
235
  : 'Props:';
193
236
  return `${header}\n${lines.join('\n')}`;
194
237
  };
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, '```');
238
+ const buildSummarySections = (component, frameworkName, framework) => [
239
+ `# ${component.name}`,
240
+ component.description,
241
+ `Category: ${component.category}`,
242
+ `Framework: ${frameworkName}`,
243
+ `Package: ${framework.packageName}`,
244
+ `Import: ${framework.importStatement}`,
245
+ `Styles: ${framework.styleImport}`,
246
+ framework.tagName ? `Custom element: <${framework.tagName}>` : '',
247
+ component.recipes.length > 0
248
+ ? `Recipes: ${component.recipes.map((recipe) => recipe.name).join(', ')}`
249
+ : 'Recipes: none'
250
+ ].filter(Boolean);
251
+ const appendBehaviorSections = (sections, behavior) => {
252
+ if (!behavior)
253
+ return;
254
+ sections.push('', '## Framework behavior', behavior.setup);
255
+ if (behavior.hook)
256
+ sections.push(`Hook: ${behavior.hook}`);
257
+ if (behavior.description)
258
+ sections.push(behavior.description);
259
+ if (behavior.options && behavior.options.length > 0) {
260
+ sections.push('', 'Hook options:', ...behavior.options.map((option) => `- ${option.name}: ${option.type} — ${option.description}`));
261
+ }
262
+ if (behavior.controller && behavior.controller.length > 0) {
263
+ sections.push('', 'Controller values:', ...behavior.controller.map((value) => `- ${value.name}: ${value.type} — ${value.description}`));
264
+ }
265
+ };
266
+ const appendGuidanceSections = (sections, component) => {
214
267
  if (component.guidance) {
215
268
  sections.push('', '## Guidance', component.guidance.when);
216
269
  if (component.guidance.distinction)
@@ -218,15 +271,27 @@ const formatComponentUsage = (component, frameworkName, detail) => {
218
271
  }
219
272
  if (component.keyboardInteractions.length > 0) {
220
273
  sections.push('', '## Keyboard interactions');
221
- sections.push(...component.keyboardInteractions.map((row) => `- ${row.key}: ${row.action}`));
274
+ sections.push(...component.keyboardInteractions.map((interaction) => `- ${interaction.key}: ${interaction.action}`));
275
+ }
276
+ if (component.apiReference.length > 0) {
277
+ sections.push('', '## API reference');
278
+ sections.push(...component.apiReference.map((row) => `- \`${row.attribute}\` ${row.values} — ${row.description}`));
222
279
  }
223
280
  if (component.runtimeEvents.length > 0) {
224
281
  sections.push('', '## Runtime events');
225
282
  sections.push(...component.runtimeEvents.map((event) => `- ${event.name}: ${event.when}`));
226
283
  }
284
+ };
285
+ const formatComponentUsage = (component, frameworkName, detail) => {
286
+ const framework = getFrameworkDetail(component.frameworkDetails, frameworkName);
287
+ const sections = buildSummarySections(component, frameworkName, framework);
288
+ if (detail === 'summary')
289
+ return sections.join('\n');
290
+ sections.push('', formatProps(framework), '', '## Example', `\`\`\`${framework.language}`, framework.example, '```');
291
+ appendBehaviorSections(sections, framework.behavior);
292
+ appendGuidanceSections(sections, component);
227
293
  if (detail === 'source') {
228
- sections.push('', `## ${frameworkName} reference source`);
229
- sections.push(`\`\`\`${framework.language}`, framework.source.trimEnd(), '```');
294
+ sections.push('', `## ${frameworkName} reference source`, `\`\`\`${framework.language}`, framework.source.trimEnd(), '```');
230
295
  }
231
296
  return sections.join('\n');
232
297
  };
@@ -298,8 +363,16 @@ export const search = (args, data = loadLumenData()) => {
298
363
  };
299
364
  }
300
365
  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));
366
+ const results = collectSearchResults(query, meaningfulSearchTerms(query), data, args.framework);
367
+ const kindPriority = {
368
+ component: 0,
369
+ recipe: 1,
370
+ rule: 3,
371
+ token: 2
372
+ };
373
+ results.sort((left, right) => right.score - left.score ||
374
+ kindPriority[left.kind] - kindPriority[right.kind] ||
375
+ left.name.localeCompare(right.name));
303
376
  const shown = results.slice(0, limit);
304
377
  if (results.length === 0) {
305
378
  return {
@@ -312,6 +385,125 @@ export const search = (args, data = loadLumenData()) => {
312
385
  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
386
  };
314
387
  };
388
+ export const getMeta = (data = loadLumenData()) => ({
389
+ data: { meta: data.meta },
390
+ text: [
391
+ '# Lumen MCP snapshot',
392
+ `Server version: ${data.meta.serverVersion}`,
393
+ `Schema version: ${data.meta.schemaVersion}`,
394
+ `Catalog hash: ${data.meta.catalogHash}`,
395
+ `Components: ${data.meta.componentCount}`,
396
+ '',
397
+ '## Package versions',
398
+ ...Object.entries(data.meta.packageVersions).map(([name, version]) => `- ${name}: ${version}`)
399
+ ].join('\n')
400
+ });
401
+ const diffManifestSection = (current, baseline) => {
402
+ const currentNames = Object.keys(current);
403
+ const baselineNames = Object.keys(baseline);
404
+ return {
405
+ added: currentNames.filter((name) => Reflect.get(baseline, name) === undefined).sort(),
406
+ changed: currentNames
407
+ .filter((name) => Reflect.get(baseline, name) !== undefined && Reflect.get(baseline, name) !== Reflect.get(current, name))
408
+ .sort(),
409
+ removed: baselineNames.filter((name) => Reflect.get(current, name) === undefined).sort(),
410
+ unchanged: currentNames.filter((name) => Reflect.get(baseline, name) === Reflect.get(current, name)).sort()
411
+ };
412
+ };
413
+ export const getCatalogManifest = (data = loadLumenData()) => ({
414
+ data: {
415
+ catalogHash: data.meta.catalogHash,
416
+ manifest: data.catalogManifest,
417
+ schemaVersion: data.meta.schemaVersion
418
+ },
419
+ text: [
420
+ '# Lumen catalog manifest',
421
+ `Catalog hash: ${data.meta.catalogHash}`,
422
+ `Components: ${Object.keys(data.catalogManifest.components).length}`,
423
+ `Recipes: ${Object.keys(data.catalogManifest.recipes).length}`,
424
+ '',
425
+ 'Keep this structured result and pass its manifest to lumen_diff_catalog after an MCP update.'
426
+ ].join('\n')
427
+ });
428
+ export const diffCatalog = (args, data = loadLumenData()) => {
429
+ const components = diffManifestSection(data.catalogManifest.components, args.baseline.components);
430
+ const recipes = diffManifestSection(data.catalogManifest.recipes, args.baseline.recipes);
431
+ const changedCount = [
432
+ ...components.added,
433
+ ...components.changed,
434
+ ...components.removed,
435
+ ...recipes.added,
436
+ ...recipes.changed,
437
+ ...recipes.removed
438
+ ].length;
439
+ const unchanged = changedCount === 0 &&
440
+ (args.baselineCatalogHash === undefined ||
441
+ args.baselineCatalogHash === data.meta.catalogHash);
442
+ return {
443
+ data: {
444
+ components,
445
+ currentCatalogHash: data.meta.catalogHash,
446
+ recipes,
447
+ unchanged
448
+ },
449
+ text: unchanged
450
+ ? `The Lumen catalog is unchanged (${data.meta.catalogHash}).`
451
+ : [
452
+ '# Lumen catalog changes',
453
+ `Current catalog hash: ${data.meta.catalogHash}`,
454
+ `Changed entries: ${changedCount}`,
455
+ `Components — added: ${components.added.length}, changed: ${components.changed.length}, removed: ${components.removed.length}`,
456
+ `Recipes — added: ${recipes.added.length}, changed: ${recipes.changed.length}, removed: ${recipes.removed.length}`
457
+ ].join('\n')
458
+ };
459
+ };
460
+ export const diagnose = (data = loadLumenData()) => {
461
+ const componentNames = data.components.map((component) => component.name);
462
+ const frameworkCoverage = {
463
+ astro: data.components.filter((component) => component.frameworks.astro).length,
464
+ elements: data.components.filter((component) => component.frameworks.elements).length,
465
+ react: data.components.filter((component) => component.frameworks.react).length
466
+ };
467
+ const checks = [
468
+ {
469
+ message: `${data.components.length} component records match metadata.`,
470
+ name: 'component-count',
471
+ status: data.components.length === data.meta.componentCount ? 'pass' : 'fail'
472
+ },
473
+ {
474
+ message: 'Component names are unique.',
475
+ name: 'unique-components',
476
+ status: new Set(componentNames).size === componentNames.length ? 'pass' : 'fail'
477
+ },
478
+ {
479
+ message: 'Every component has a manifest fingerprint.',
480
+ name: 'component-manifest',
481
+ status: componentNames.every((name) => Reflect.get(data.catalogManifest.components, name)) ? 'pass' : 'fail'
482
+ },
483
+ {
484
+ message: 'Every available framework contract has an example and import statement.',
485
+ name: 'framework-contracts',
486
+ status: data.components.every((component) => Object.entries(component.frameworks).every(([framework, available]) => {
487
+ if (!available)
488
+ return true;
489
+ const details = Reflect.get(component.frameworkDetails, framework);
490
+ return Boolean(details?.example.trim()) && Boolean(details?.importStatement.trim());
491
+ })) ? 'pass' : 'fail'
492
+ }
493
+ ];
494
+ const status = checks.every((check) => check.status === 'pass') ? 'healthy' : 'issues';
495
+ return {
496
+ data: { checks, frameworkCoverage, status },
497
+ isError: status === 'issues',
498
+ text: [
499
+ `# Lumen MCP diagnostics: ${status}`,
500
+ `Catalog hash: ${data.meta.catalogHash}`,
501
+ `Framework coverage — Astro: ${frameworkCoverage.astro}, React: ${frameworkCoverage.react}, Elements: ${frameworkCoverage.elements}`,
502
+ '',
503
+ ...checks.map((check) => `- ${check.status.toUpperCase()} ${check.name}: ${check.message}`)
504
+ ].join('\n')
505
+ };
506
+ };
315
507
  export const getTokens = (data = loadLumenData()) => {
316
508
  const { tokens } = data;
317
509
  const colorLines = Object.entries(tokens.colors).map(([name, value]) => `- ${name}: hsl(${value})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@santi020k/lumen-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -29,20 +29,23 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "bin": {
32
- "lumen-mcp": "./bin/lumen-mcp.mjs"
32
+ "lumen-mcp": "./bin/lumen-mcp.mjs",
33
+ "lumen-mcp-http": "./bin/lumen-mcp-http.mjs"
33
34
  },
34
35
  "dependencies": {
35
36
  "@modelcontextprotocol/sdk": "^1.29.0",
36
37
  "zod": "^4.4.3"
37
38
  },
38
39
  "devDependencies": {
40
+ "@astrojs/compiler": "^3.0.1",
41
+ "@types/express": "^5.0.6",
39
42
  "@types/node": "^26.1.1",
40
43
  "@vitest/coverage-v8": "^4.1.10",
41
44
  "typescript": "^6.0.3",
42
45
  "vitest": "^4.1.10"
43
46
  },
44
47
  "engines": {
45
- "node": ">=22.18.0"
48
+ "node": ">=20.20.0"
46
49
  },
47
50
  "repository": {
48
51
  "type": "git",
@@ -69,9 +72,14 @@
69
72
  "license": "MIT",
70
73
  "scripts": {
71
74
  "generate": "node scripts/generate-data.mjs",
75
+ "check:snapshot": "node scripts/generate-data.mjs --check",
72
76
  "build": "pnpm run generate && tsc -p tsconfig.build.json",
73
77
  "dev": "pnpm run generate && tsc -p tsconfig.build.json --watch --preserveWatchOutput",
74
78
  "local": "node bin/lumen-mcp-local.mjs",
79
+ "eval": "pnpm --filter @santi020k/lumen-react... run build && pnpm run build && node scripts/evaluate.mjs && node scripts/validate-examples.mjs",
80
+ "eval:examples": "pnpm --filter @santi020k/lumen-react... run build && pnpm run build && node scripts/validate-examples.mjs",
81
+ "eval:search": "pnpm run build && node scripts/evaluate.mjs --search-only",
82
+ "smoke:package": "pnpm run build && node scripts/smoke-package.mjs",
75
83
  "typecheck": "tsc -p tsconfig.json --noEmit",
76
84
  "test": "vitest run --config ../../vitest.config.ts --project mcp",
77
85
  "test:coverage": "vitest run --config ../../vitest.config.ts --project mcp --coverage",