@santi020k/lumen-mcp 0.1.0 → 0.2.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,29 @@ 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)
43
+ const frameworkSearchText = (component, framework) => (framework
44
+ ? [component.frameworkDetails[framework]]
45
+ : Object.values(component.frameworkDetails))
27
46
  .flatMap((framework) => [
28
47
  framework.packageName,
48
+ framework.behavior?.description ?? '',
49
+ framework.behavior?.hook ?? '',
50
+ framework.behavior?.mode ?? '',
51
+ framework.behavior?.setup ?? '',
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
  ])
34
58
  .join(' ');
35
- const componentSearchText = (component) => [
59
+ const componentSearchText = (component, framework) => [
36
60
  component.name,
37
61
  component.kebab,
38
- 'accessible accessibility',
39
62
  component.description,
40
63
  component.category,
41
64
  component.collections.join(' '),
65
+ component.recipes.map((recipe) => recipe.name).join(' '),
42
66
  component.keywords.join(' '),
43
67
  component.dependencies.join(' '),
44
68
  component.guidance?.when ?? '',
@@ -46,16 +70,23 @@ const componentSearchText = (component) => [
46
70
  component.apiReference
47
71
  .map((row) => `${row.attribute} ${row.values} ${row.description}`)
48
72
  .join(' '),
49
- frameworkSearchText(component)
73
+ frameworkSearchText(component, framework)
50
74
  ].join(' ');
51
75
  const scoreCandidate = (query, terms, searchableText, primaryName = '') => {
52
76
  const normalizedText = normalize(searchableText);
53
- const matchedTerms = terms.filter((term) => normalizedText.includes(term));
54
- if (matchedTerms.length !== terms.length)
77
+ const matchedTerms = terms.filter((term) => (searchTermAliases[term] ?? [term]).some((candidate) => normalizedText.includes(candidate)));
78
+ if (matchedTerms.length === 0)
55
79
  return { matchedTerms, score: 0 };
56
- const normalizedQuery = normalize(query);
80
+ const normalizedQuery = terms.join('-');
57
81
  const normalizedName = normalize(primaryName);
58
- let score = terms.reduce((total, term) => total + (normalizedName.includes(term) ? 20 : 5), 0);
82
+ const coverage = matchedTerms.length / terms.length;
83
+ let score = matchedTerms.reduce((total, term) => total + ((searchTermAliases[term] ?? [term]).some((candidate) => normalizedName.includes(candidate))
84
+ ? 20
85
+ : 5), 0);
86
+ score += Math.round(coverage * 80);
87
+ score -= (terms.length - matchedTerms.length) * 3;
88
+ if (terms.includes(normalizedName))
89
+ score += 50;
59
90
  if (normalizedText.includes(normalizedQuery))
60
91
  score += 100;
61
92
  if (normalizedName === normalizedQuery)
@@ -63,20 +94,22 @@ const scoreCandidate = (query, terms, searchableText, primaryName = '') => {
63
94
  return { matchedTerms, score };
64
95
  };
65
96
  const appendSearchResult = (results, candidate, query, terms, searchableText) => {
66
- const match = scoreCandidate(query, terms, searchableText, candidate.name);
97
+ const match = scoreCandidate(query, terms, searchableText, candidate.kind === 'rule' ? '' : candidate.name);
67
98
  if (match.score > 0)
68
99
  results.push({ ...candidate, ...match });
69
100
  };
70
- const collectSearchResults = (query, terms, data) => {
101
+ const collectSearchResults = (query, terms, data, framework) => {
71
102
  const results = [];
72
103
  for (const component of data.components) {
104
+ if (framework && !component.frameworks[framework])
105
+ continue;
73
106
  appendSearchResult(results, {
74
107
  category: component.category,
75
108
  description: component.description,
76
109
  frameworks: availableFrameworks(component),
77
110
  kind: 'component',
78
111
  name: component.name
79
- }, query, terms, componentSearchText(component));
112
+ }, query, terms, componentSearchText(component, framework));
80
113
  }
81
114
  for (const recipe of data.recipes) {
82
115
  appendSearchResult(results, {
@@ -104,14 +137,17 @@ const collectSearchResults = (query, terms, data) => {
104
137
  };
105
138
  export const listComponents = (args = {}, data = loadLumenData()) => {
106
139
  const query = args.query?.trim();
107
- const queryTerms = query ? tokenize(query) : [];
140
+ const queryTerms = query ? meaningfulSearchTerms(query) : [];
141
+ const recipe = args.recipe ? resolveRecipe(args.recipe, data) : undefined;
142
+ const recipeName = recipe?.name ?? (args.recipe ? normalize(args.recipe) : undefined);
108
143
  const matches = data.components.filter((component) => {
109
144
  if (args.framework && !component.frameworks[args.framework])
110
145
  return false;
111
- if (args.recipe && !component.recipes.some((recipe) => recipe.name === args.recipe))
146
+ if (recipeName &&
147
+ !component.recipes.some((componentRecipe) => normalize(componentRecipe.name) === recipeName))
112
148
  return false;
113
149
  if (query &&
114
- scoreCandidate(query, queryTerms, componentSearchText(component), component.name).score === 0)
150
+ scoreCandidate(query, queryTerms, componentSearchText(component, args.framework), component.name).score === 0)
115
151
  return false;
116
152
  return true;
117
153
  });
@@ -181,6 +217,9 @@ const componentDetail = (component, framework, detail) => {
181
217
  : usage;
182
218
  };
183
219
  const formatProps = (framework) => {
220
+ if (framework.attributes && framework.attributes.length > 0) {
221
+ return `Attributes:\n${framework.attributes.map((attribute) => `- ${attribute}`).join('\n')}`;
222
+ }
184
223
  if (framework.props.length === 0) {
185
224
  return framework.propsExtends
186
225
  ? `Props extend ${framework.propsExtends}; no additional local props were detected.`
@@ -211,6 +250,19 @@ const formatComponentUsage = (component, frameworkName, detail) => {
211
250
  return sections.join('\n');
212
251
  sections.push('', formatProps(framework), '', '## Example', `\`\`\`${framework.language}`);
213
252
  sections.push(framework.example, '```');
253
+ if (framework.behavior) {
254
+ sections.push('', '## Framework behavior', framework.behavior.setup);
255
+ if (framework.behavior.hook)
256
+ sections.push(`Hook: ${framework.behavior.hook}`);
257
+ if (framework.behavior.description)
258
+ sections.push(framework.behavior.description);
259
+ if (framework.behavior.options && framework.behavior.options.length > 0) {
260
+ sections.push('', 'Hook options:', ...framework.behavior.options.map((option) => `- ${option.name}: ${option.type} — ${option.description}`));
261
+ }
262
+ if (framework.behavior.controller && framework.behavior.controller.length > 0) {
263
+ sections.push('', 'Controller values:', ...framework.behavior.controller.map((value) => `- ${value.name}: ${value.type} — ${value.description}`));
264
+ }
265
+ }
214
266
  if (component.guidance) {
215
267
  sections.push('', '## Guidance', component.guidance.when);
216
268
  if (component.guidance.distinction)
@@ -298,8 +350,16 @@ export const search = (args, data = loadLumenData()) => {
298
350
  };
299
351
  }
300
352
  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));
353
+ const results = collectSearchResults(query, meaningfulSearchTerms(query), data, args.framework);
354
+ const kindPriority = {
355
+ component: 0,
356
+ recipe: 1,
357
+ rule: 3,
358
+ token: 2
359
+ };
360
+ results.sort((left, right) => right.score - left.score ||
361
+ kindPriority[left.kind] - kindPriority[right.kind] ||
362
+ left.name.localeCompare(right.name));
303
363
  const shown = results.slice(0, limit);
304
364
  if (results.length === 0) {
305
365
  return {
@@ -312,6 +372,122 @@ export const search = (args, data = loadLumenData()) => {
312
372
  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
373
  };
314
374
  };
375
+ export const getMeta = (data = loadLumenData()) => ({
376
+ data: { meta: data.meta },
377
+ text: [
378
+ '# Lumen MCP snapshot',
379
+ `Server version: ${data.meta.serverVersion}`,
380
+ `Schema version: ${data.meta.schemaVersion}`,
381
+ `Catalog hash: ${data.meta.catalogHash}`,
382
+ `Components: ${data.meta.componentCount}`,
383
+ '',
384
+ '## Package versions',
385
+ ...Object.entries(data.meta.packageVersions).map(([name, version]) => `- ${name}: ${version}`)
386
+ ].join('\n')
387
+ });
388
+ const diffManifestSection = (current, baseline) => {
389
+ const currentNames = Object.keys(current);
390
+ const baselineNames = Object.keys(baseline);
391
+ return {
392
+ added: currentNames.filter((name) => baseline[name] === undefined).sort(),
393
+ changed: currentNames
394
+ .filter((name) => baseline[name] !== undefined && baseline[name] !== current[name])
395
+ .sort(),
396
+ removed: baselineNames.filter((name) => current[name] === undefined).sort(),
397
+ unchanged: currentNames.filter((name) => baseline[name] === current[name]).sort()
398
+ };
399
+ };
400
+ export const getCatalogManifest = (data = loadLumenData()) => ({
401
+ data: {
402
+ catalogHash: data.meta.catalogHash,
403
+ manifest: data.catalogManifest,
404
+ schemaVersion: data.meta.schemaVersion
405
+ },
406
+ text: [
407
+ '# Lumen catalog manifest',
408
+ `Catalog hash: ${data.meta.catalogHash}`,
409
+ `Components: ${Object.keys(data.catalogManifest.components).length}`,
410
+ `Recipes: ${Object.keys(data.catalogManifest.recipes).length}`,
411
+ '',
412
+ 'Keep this structured result and pass its manifest to lumen_diff_catalog after an MCP update.'
413
+ ].join('\n')
414
+ });
415
+ export const diffCatalog = (args, data = loadLumenData()) => {
416
+ const components = diffManifestSection(data.catalogManifest.components, args.baseline.components);
417
+ const recipes = diffManifestSection(data.catalogManifest.recipes, args.baseline.recipes);
418
+ const changedCount = [
419
+ ...components.added,
420
+ ...components.changed,
421
+ ...components.removed,
422
+ ...recipes.added,
423
+ ...recipes.changed,
424
+ ...recipes.removed
425
+ ].length;
426
+ const unchanged = changedCount === 0 &&
427
+ (args.baselineCatalogHash === undefined ||
428
+ args.baselineCatalogHash === data.meta.catalogHash);
429
+ return {
430
+ data: {
431
+ components,
432
+ currentCatalogHash: data.meta.catalogHash,
433
+ recipes,
434
+ unchanged
435
+ },
436
+ text: unchanged
437
+ ? `The Lumen catalog is unchanged (${data.meta.catalogHash}).`
438
+ : [
439
+ '# Lumen catalog changes',
440
+ `Current catalog hash: ${data.meta.catalogHash}`,
441
+ `Changed entries: ${changedCount}`,
442
+ `Components — added: ${components.added.length}, changed: ${components.changed.length}, removed: ${components.removed.length}`,
443
+ `Recipes — added: ${recipes.added.length}, changed: ${recipes.changed.length}, removed: ${recipes.removed.length}`
444
+ ].join('\n')
445
+ };
446
+ };
447
+ export const diagnose = (data = loadLumenData()) => {
448
+ const componentNames = data.components.map((component) => component.name);
449
+ const frameworkCoverage = {
450
+ astro: data.components.filter((component) => component.frameworks.astro).length,
451
+ elements: data.components.filter((component) => component.frameworks.elements).length,
452
+ react: data.components.filter((component) => component.frameworks.react).length
453
+ };
454
+ const checks = [
455
+ {
456
+ message: `${data.components.length} component records match metadata.`,
457
+ name: 'component-count',
458
+ status: data.components.length === data.meta.componentCount ? 'pass' : 'fail'
459
+ },
460
+ {
461
+ message: 'Component names are unique.',
462
+ name: 'unique-components',
463
+ status: new Set(componentNames).size === componentNames.length ? 'pass' : 'fail'
464
+ },
465
+ {
466
+ message: 'Every component has a manifest fingerprint.',
467
+ name: 'component-manifest',
468
+ status: componentNames.every((name) => data.catalogManifest.components[name]) ? 'pass' : 'fail'
469
+ },
470
+ {
471
+ message: 'Every available framework contract has an example and import statement.',
472
+ name: 'framework-contracts',
473
+ status: data.components.every((component) => Object.entries(component.frameworks).every(([framework, available]) => !available ||
474
+ Boolean(component.frameworkDetails[framework].example.trim()) &&
475
+ Boolean(component.frameworkDetails[framework].importStatement.trim()))) ? 'pass' : 'fail'
476
+ }
477
+ ];
478
+ const status = checks.every((check) => check.status === 'pass') ? 'healthy' : 'issues';
479
+ return {
480
+ data: { checks, frameworkCoverage, status },
481
+ isError: status === 'issues',
482
+ text: [
483
+ `# Lumen MCP diagnostics: ${status}`,
484
+ `Catalog hash: ${data.meta.catalogHash}`,
485
+ `Framework coverage — Astro: ${frameworkCoverage.astro}, React: ${frameworkCoverage.react}, Elements: ${frameworkCoverage.elements}`,
486
+ '',
487
+ ...checks.map((check) => `- ${check.status.toUpperCase()} ${check.name}: ${check.message}`)
488
+ ].join('\n')
489
+ };
490
+ };
315
491
  export const getTokens = (data = loadLumenData()) => {
316
492
  const { tokens } = data;
317
493
  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.2.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",