@lumeo-ui/mcp-server 2.0.0-rc.4 → 2.0.0-rc.40

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/index.js CHANGED
@@ -1,29 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Lumeo MCP Server
3
+ * Lumeo MCP Server v2.0.0
4
4
  *
5
- * Exposes Lumeo's full 125-component catalog (sourced from the generated
6
- * registry.json, enriched by hand-curated rich entries for the top 35)
7
- * to MCP-compatible LLM clients (Claude Desktop, Cursor, Copilot, etc.):
5
+ * Source-of-truth schema for ALL 131 Lumeo components, generated at build time
6
+ * by `tools/Lumeo.RegistryGen` from the actual Razor source via Roslyn. Every
7
+ * component now ships full parameter / enum / event / sub-component metadata —
8
+ * no thin/rich split, no manual catalog drift.
8
9
  *
9
10
  * Tools:
10
- * - lumeo_list_components — list/filter the full catalog
11
- * - lumeo_get_component — rich schema when curated, thin otherwise
12
- * - lumeo_search — fuzzy text search across all 125
11
+ * - lumeo_list_components — list/filter all 131 components (name+category+description)
12
+ * - lumeo_get_component — full schema (params, enums, events, sub-components, files, examples)
13
+ * - lumeo_search — fuzzy search across name/category/description
14
+ * - lumeo_get_example — working Razor snippet(s) for a component
15
+ * - lumeo_get_install — NuGet + @using + DI + host includes + gotchas
16
+ * - lumeo_validate_markup — pre-flight check Razor for hallucinated APIs / bad enums / bad nesting
17
+ * - lumeo_get_theme_tokens — the colour/radius CSS-variable tokens (the only legal colours)
18
+ * - lumeo_list_patterns / lumeo_get_pattern — full-page composed examples (dashboard, auth, …)
19
+ * - lumeo_changelog — current version + schema generation timestamp + changelog link
13
20
  *
14
21
  * Resources (URI template):
15
22
  * - lumeo://component/{name} — markdown reference per component
16
- * - lumeo://category/{name} — all components in a category
23
+ * - lumeo://category/{name} — overview of all components in a category
17
24
  *
18
25
  * Transport: stdio (the standard for spawned MCP servers).
19
26
  */
20
27
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
29
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
23
- import { catalog, CATEGORIES, registry, } from "./components.js";
30
+ import { loadComponentsApi, } from "./componentsApi.js";
31
+ import { components as curatedExamples } from "./components.js";
32
+ import { setupFor, PORTAL_COMPONENTS, NEEDS_OVERLAY_PROVIDER } from "./installInfo.js";
24
33
  const DOCS_BASE = "https://lumeo.nativ.sh";
34
+ // ───────────────── Load source-of-truth schema ─────────────────
35
+ const api = loadComponentsApi() ?? {
36
+ version: "0.0.0",
37
+ generated: "",
38
+ stats: { componentCount: 0, totalParameters: 0, totalEnums: 0, totalRecords: 0, thinFallbacks: [] },
39
+ components: {},
40
+ };
41
+ const components = Object.values(api.components).sort((a, b) => a.name.localeCompare(b.name));
42
+ const byName = new Map(components.map((c) => [c.name.toLowerCase(), c]));
43
+ const CATEGORIES = Array.from(new Set(components.map((c) => c.category))).sort();
44
+ const themeTokens = api.themeTokens ?? [];
45
+ const patterns = api.patterns ?? [];
46
+ const patternByKey = new Map(patterns.map((p) => [p.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""), p]));
47
+ // Hand-curated examples overlay (~30 components). Auto-gen schema wins for
48
+ // parameters/enums/events; the curated `example` Razor snippet is preserved
49
+ // as a documentation aid because LLMs benefit from seeing real usage.
50
+ const curatedExampleByName = new Map(curatedExamples.map((c) => [c.name.toLowerCase(), c.example]));
25
51
  // ───────────────── Helpers ─────────────────
26
- const byName = new Map(catalog.map((c) => [c.name.toLowerCase(), c]));
27
52
  function findComponent(name) {
28
53
  return byName.get(name.toLowerCase());
29
54
  }
@@ -45,10 +70,9 @@ function score(c, q) {
45
70
  return s;
46
71
  }
47
72
  function searchCatalog(query, category) {
48
- let pool = catalog;
49
- if (category) {
73
+ let pool = components;
74
+ if (category)
50
75
  pool = pool.filter((c) => c.category.toLowerCase() === category.toLowerCase());
51
- }
52
76
  if (!query)
53
77
  return pool;
54
78
  return pool
@@ -58,96 +82,256 @@ function searchCatalog(query, category) {
58
82
  .map((x) => x.c);
59
83
  }
60
84
  function docsUrl(c) {
61
- return `${DOCS_BASE}/components/${c.slug}`;
85
+ // Convert PascalCase to kebab-case for the docs URL
86
+ const slug = c.name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
87
+ return `${DOCS_BASE}/components/${slug}`;
88
+ }
89
+ function paramRow(p) {
90
+ const def = p.default ?? "—";
91
+ const desc = p.description ?? "";
92
+ const flags = [];
93
+ if (p.isCascading)
94
+ flags.push("cascading");
95
+ if (p.captureUnmatched)
96
+ flags.push("captures unmatched");
97
+ const flagStr = flags.length ? ` _(${flags.join(", ")})_` : "";
98
+ return `| \`${p.name}\` | \`${p.type}\` | \`${def}\` | ${desc}${flagStr} |`;
62
99
  }
63
- function toRichMarkdown(c) {
64
- const paramRows = c.params
65
- .map((p) => `| \`${p.name}\` | \`${p.type}\` | \`${p.default}\` | ${p.description} |`)
100
+ function toComponentMarkdown(c) {
101
+ const paramRows = c.parameters.map(paramRow).join("\n");
102
+ const enumRows = c.enums
103
+ .map((e) => `- **${e.name}**: ${e.values.join(", ")}${e.description ? ` — ${e.description}` : ""}`)
66
104
  .join("\n");
67
- const slotRows = c.slots
68
- .map((s) => `| \`${s.name}\` | ${s.description} |`)
105
+ const eventRows = c.events
106
+ .map((e) => `- **${e.name}** \`${e.type}\`${e.description ? ` — ${e.description}` : ""}`)
107
+ .join("\n");
108
+ const subRows = Object.values(c.subComponents)
109
+ .map((s) => `- **${s.componentName}** (${s.parameters.length} params)`)
69
110
  .join("\n");
70
- const cssVarsBlock = c.cssVars.length
71
- ? `## CSS Variables\n\n${c.cssVars.map((v) => `- \`${v}\``).join("\n")}\n\n`
72
- : "";
73
- return [
74
- `# ${c.name}`,
75
- ``,
76
- `**Category:** ${c.category}`,
77
- ``,
78
- c.description,
79
- ``,
80
- `## Parameters`,
81
- ``,
82
- `| Param | Type | Default | Description |`,
83
- `|---|---|---|---|`,
84
- paramRows || `| _(none)_ | | | |`,
85
- ``,
86
- c.slots.length ? `## Slots\n\n| Slot | Description |\n|---|---|\n${slotRows}\n` : ``,
87
- `## Example`,
88
- ``,
89
- "```razor",
90
- c.example,
91
- "```",
92
- ``,
93
- cssVarsBlock,
94
- `_Docs: ${docsUrl(c)}_`,
95
- ].filter(Boolean).join("\n");
96
- }
97
- function toThinMarkdown(c) {
98
111
  const filesBlock = c.files.length
99
- ? `## Files\n\n${c.files.map((f) => `- \`${f}\``).join("\n")}\n\n`
100
- : "";
101
- const cssVarsBlock = c.cssVars.length
102
- ? `## CSS Variables\n\n${c.cssVars.map((v) => `- \`${v}\``).join("\n")}\n\n`
112
+ ? c.files.map((f) => `- \`${f}\``).join("\n")
103
113
  : "";
104
- const depsBlock = c.dependencies.length
105
- ? `## Dependencies\n\n${c.dependencies.map((d) => `- \`${d}\``).join("\n")}\n\n`
106
- : "";
107
- return [
114
+ const example = curatedExampleByName.get(c.name.toLowerCase());
115
+ const sections = [
108
116
  `# ${c.name}`,
109
- ``,
110
- `**Category:** ${c.category}`,
111
- ``,
117
+ "",
118
+ `**Category:** ${c.category}${c.subcategory ? ` › ${c.subcategory}` : ""}`,
119
+ `**NuGet:** \`${c.nugetPackage}\``,
120
+ `**Namespace:** \`${c.namespace ?? "Lumeo"}\``,
121
+ "",
112
122
  c.description,
113
- ``,
114
- `> Rich schema (parameters, slots, Razor example) is coming soon for this component.`,
115
- `> See the docs site for full usage: [${docsUrl(c)}](${docsUrl(c)})`,
116
- ``,
117
- filesBlock,
118
- depsBlock,
119
- cssVarsBlock,
120
- ].filter(Boolean).join("\n");
121
- }
122
- function toComponentMarkdown(c) {
123
- return c.thin ? toThinMarkdown(c) : toRichMarkdown(c);
123
+ "",
124
+ "## Parameters",
125
+ "",
126
+ "| Name | Type | Default | Description |",
127
+ "|---|---|---|---|",
128
+ paramRows || "| _(none)_ | | | |",
129
+ "",
130
+ ];
131
+ if (c.enums.length)
132
+ sections.push("## Enums", "", enumRows, "");
133
+ if (c.events.length)
134
+ sections.push("## Events", "", eventRows, "");
135
+ if (Object.keys(c.subComponents).length)
136
+ sections.push("## Sub-components", "", subRows, "");
137
+ if (example)
138
+ sections.push("## Example", "", "```razor", example, "```", "");
139
+ if (filesBlock)
140
+ sections.push("## Source files", "", filesBlock, "");
141
+ sections.push(`_Docs: ${docsUrl(c)}_`);
142
+ return sections.join("\n");
124
143
  }
125
144
  function toCategoryMarkdown(category) {
126
- const inCat = catalog.filter((c) => c.category.toLowerCase() === category.toLowerCase());
127
- if (inCat.length === 0) {
128
- return `# ${category}\n\nNo components documented in this category yet.`;
129
- }
145
+ const inCat = components.filter((c) => c.category.toLowerCase() === category.toLowerCase());
146
+ if (inCat.length === 0)
147
+ return `# ${category}\n\nNo components in this category.`;
130
148
  const rows = inCat
131
- .map((c) => `| [\`${c.name}\`](lumeo://component/${c.name}) | ${c.thin ? "" : "*"}${c.description}${c.thin ? "" : "*"} |`)
149
+ .map((c) => `| [\`${c.name}\`](lumeo://component/${c.name}) | ${c.description} |`)
132
150
  .join("\n");
133
151
  return [
134
152
  `# ${category}`,
135
- ``,
153
+ "",
136
154
  `${inCat.length} component${inCat.length === 1 ? "" : "s"}:`,
137
- ``,
155
+ "",
138
156
  `| Component | Description |`,
139
157
  `|---|---|`,
140
158
  rows,
141
- ``,
159
+ "",
142
160
  ].join("\n");
143
161
  }
144
162
  function toListPayload(c) {
145
163
  return {
146
164
  name: c.name,
147
165
  category: c.category,
166
+ subcategory: c.subcategory,
167
+ description: c.description,
168
+ nugetPackage: c.nugetPackage,
169
+ };
170
+ }
171
+ function toGetPayload(c) {
172
+ // Build a rich JSON payload covering everything Claude Code needs to write
173
+ // correct Razor without consulting external docs.
174
+ const subComponents = Object.values(c.subComponents).map((s) => ({
175
+ name: s.componentName,
176
+ namespace: s.namespace,
177
+ inheritsFrom: s.inheritsFrom,
178
+ implements: s.implements,
179
+ parameters: s.parameters,
180
+ events: s.events,
181
+ enums: s.enums,
182
+ records: s.records,
183
+ }));
184
+ return {
185
+ name: c.name,
186
+ category: c.category,
187
+ subcategory: c.subcategory,
148
188
  description: c.description,
189
+ nugetPackage: c.nugetPackage,
190
+ namespace: c.namespace,
191
+ inheritsFrom: c.inheritsFrom,
192
+ implements: c.implements,
193
+ parameters: c.parameters,
194
+ events: c.events,
195
+ enums: c.enums,
196
+ records: c.records,
197
+ cssVars: c.cssVars,
198
+ files: c.files,
199
+ subComponents,
200
+ examples: c.examples ?? [],
201
+ curatedExample: curatedExampleByName.get(c.name.toLowerCase()) ?? null,
202
+ docs: docsUrl(c),
203
+ };
204
+ }
205
+ // ───────────────── lumeo_get_install ─────────────────
206
+ function buildInstallInfo(c) {
207
+ const setup = setupFor(c.nugetPackage);
208
+ const subNames = Object.values(c.subComponents).map((s) => s.componentName);
209
+ const isPortal = PORTAL_COMPONENTS.has(c.name);
210
+ const needsOverlayProvider = NEEDS_OVERLAY_PROVIDER.has(c.name);
211
+ const requiredParams = c.parameters
212
+ .filter((p) => /\bEditorRequired\b/i.test(p.description ?? "") || /required/i.test(p.description ?? ""))
213
+ .map((p) => p.name);
214
+ return {
215
+ component: c.name,
216
+ package: setup.package,
217
+ install: {
218
+ dotnet: setup.dotnetAdd,
219
+ registryCli: setup.lumeoAddNote,
220
+ },
221
+ imports: setup.usings,
222
+ di: setup.di,
223
+ hostIncludes: setup.hostIncludes,
224
+ namespace: c.namespace ?? "Lumeo",
225
+ subComponents: subNames,
226
+ requirements: [
227
+ isPortal
228
+ ? "Portal component: the page <body> (or an ancestor of the overlay root) needs the theme classes `bg-background text-foreground`, or this renders outside the theme cascade and looks unstyled."
229
+ : null,
230
+ needsOverlayProvider
231
+ ? "For the service-driven API (e.g. ToastService / OverlayService), add an <OverlayProvider /> once in your layout."
232
+ : null,
233
+ requiredParams.length
234
+ ? `Required parameters: ${requiredParams.map((p) => `\`${p}\``).join(", ")}.`
235
+ : null,
236
+ /Overlay/i.test(JSON.stringify(c.implements)) || isPortal
237
+ ? "Overlay/dismiss patterns are handled internally (RegisterClickOutside / LockScroll / focus trap) — you don't wire those yourself."
238
+ : null,
239
+ ].filter(Boolean),
240
+ notes: setup.notes,
241
+ docs: docsUrl(c),
149
242
  };
150
243
  }
244
+ // Build a lookup of every component + sub-component → its parameter set,
245
+ // plus the parent each sub-component must live inside.
246
+ const elementIndex = (() => {
247
+ const m = new Map();
248
+ const enumValueSet = (vals) => new Set(vals.map((v) => v.toLowerCase()));
249
+ for (const c of components) {
250
+ const enums = new Map();
251
+ for (const e of c.enums)
252
+ enums.set(e.name, enumValueSet(e.values));
253
+ m.set(c.name.toLowerCase(), { params: new Set(c.parameters.map((p) => p.name)), enums });
254
+ for (const s of Object.values(c.subComponents)) {
255
+ const subEnums = new Map();
256
+ for (const e of s.enums)
257
+ subEnums.set(e.name, enumValueSet(e.values));
258
+ m.set(s.componentName.toLowerCase(), { params: new Set(s.parameters.map((p) => p.name)), enums: subEnums, parent: c.name });
259
+ }
260
+ }
261
+ return m;
262
+ })();
263
+ function validateMarkup(markup) {
264
+ const issues = [];
265
+ // Find component tags: <Foo ...> or <Foo .../> or </Foo>. Lumeo components are PascalCase.
266
+ const tagRx = /<\/?([A-Z][A-Za-z0-9]*)((?:\s+[^<>]*?)?)\/?>/g;
267
+ // Track which known Lumeo components appear, to validate parent-child.
268
+ const present = new Set();
269
+ let m;
270
+ while ((m = tagRx.exec(markup)) !== null) {
271
+ const tag = m[1];
272
+ const isClose = m[0].startsWith("</");
273
+ const known = elementIndex.get(tag.toLowerCase());
274
+ if (!known)
275
+ continue; // not a Lumeo element (could be a user component / HTML — ignore)
276
+ present.add(tag);
277
+ if (isClose)
278
+ continue;
279
+ // Parse attributes (best-effort): Name="..." | Name='...' | Name="@expr" | Name=@expr | bare-name
280
+ const attrBlob = m[2] ?? "";
281
+ const attrRx = /([@A-Za-z_][\w-]*)\s*=\s*("(?:[^"]*)"|'(?:[^']*)'|@?[^\s"'<>]+)|([@A-Za-z_][\w-]*)(?=\s|$)/g;
282
+ let am;
283
+ while ((am = attrRx.exec(attrBlob)) !== null) {
284
+ let name = (am[1] ?? am[3] ?? "").trim();
285
+ if (!name)
286
+ continue;
287
+ // Strip Blazor directive prefixes/suffixes: @bind-Foo, @bind-Foo:event, Foo:stopPropagation, @onclick, @attributes, @key, @ref, @bind
288
+ if (name.startsWith("@bind-"))
289
+ name = name.slice("@bind-".length).split(":")[0];
290
+ else if (name.startsWith("@bind"))
291
+ continue; // @bind / @bind:event on inputs — skip
292
+ else if (name.startsWith("@on") || name === "@attributes" || name === "@key" || name === "@ref" || name === "@oninput" || name === "@onchange")
293
+ continue;
294
+ else if (name.startsWith("@"))
295
+ continue; // other directives
296
+ if (name.includes(":"))
297
+ name = name.split(":")[0]; // EventName:stopPropagation, :preventDefault
298
+ if (name === "class" || name === "style" || name === "id")
299
+ continue; // pass-through HTML attrs (Lumeo captures unmatched)
300
+ if (/^data-|^aria-/i.test(name))
301
+ continue; // captured unmatched
302
+ if (!known.params.has(name)) {
303
+ // Could be a captured-unmatched HTML attr — only flag if it looks like a typo'd Lumeo param (PascalCase).
304
+ if (/^[A-Z]/.test(name)) {
305
+ issues.push({ severity: "warning", component: tag, message: `Unknown parameter \`${name}\` on <${tag}>. Did you mean one of: ${[...known.params].slice(0, 8).join(", ")}…? (Or it's a pass-through HTML attribute, which is allowed.)` });
306
+ }
307
+ continue;
308
+ }
309
+ // Enum value check: Foo="Bar.Baz.Qux" or Foo="Qux"
310
+ const rawVal = (am[2] ?? "").replace(/^["']|["']$/g, "").trim();
311
+ if (!rawVal || rawVal.startsWith("@"))
312
+ continue; // dynamic expression — can't statically check
313
+ // Which enum does this param use? Match by param name heuristically against enum names.
314
+ for (const [enumName, vals] of known.enums) {
315
+ // crude: the param likely uses this enum if rawVal looks like EnumName.Value or matches a value
316
+ const lastSeg = rawVal.split(".").pop().toLowerCase();
317
+ const looksLikeThisEnum = rawVal.toLowerCase().includes(enumName.toLowerCase()) || vals.has(lastSeg);
318
+ if (looksLikeThisEnum && !vals.has(lastSeg)) {
319
+ issues.push({ severity: "error", component: tag, message: `\`${name}="${rawVal}"\` — \`${lastSeg}\` is not a valid ${enumName} value. Allowed: ${[...vals].join(", ")}.` });
320
+ }
321
+ }
322
+ }
323
+ }
324
+ // Parent-child: every sub-component present should have its required parent present somewhere.
325
+ for (const tag of present) {
326
+ const known = elementIndex.get(tag.toLowerCase());
327
+ if (known.parent && !present.has(known.parent)) {
328
+ issues.push({ severity: "error", component: tag, message: `<${tag}> must be used inside <${known.parent}> (it reads a CascadingValue from it). No <${known.parent}> found in this markup.` });
329
+ }
330
+ }
331
+ // Also flag obviously-unknown PascalCase tags that look like attempted Lumeo components.
332
+ // (Skip — too noisy; user components are also PascalCase. The known-element checks above are enough.)
333
+ return { ok: !issues.some((i) => i.severity === "error"), issues };
334
+ }
151
335
  // ───────────────── Server setup ─────────────────
152
336
  const server = new Server({
153
337
  name: "lumeo-mcp",
@@ -163,54 +347,119 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
163
347
  tools: [
164
348
  {
165
349
  name: "lumeo_list_components",
166
- description: `List all ${catalog.length} Lumeo components, optionally filtered by category or a free-text query. ` +
167
- "Returns an array of { name, category, description }. " +
168
- `Known categories: ${CATEGORIES.join(", ")}.`,
350
+ description: `List all ${components.length} Lumeo components, optionally filtered by category or query. ` +
351
+ "Returns { name, category, subcategory, description, nugetPackage } per component. " +
352
+ `Categories: ${CATEGORIES.join(", ")}.`,
169
353
  inputSchema: {
170
354
  type: "object",
171
355
  properties: {
172
- category: {
173
- type: "string",
174
- description: `Filter by category (${CATEGORIES.join(", ")}).`,
175
- },
176
- query: {
177
- type: "string",
178
- description: "Free-text query matched against name, category, and description.",
179
- },
356
+ category: { type: "string", description: `Filter by category (${CATEGORIES.join(", ")}).` },
357
+ query: { type: "string", description: "Free-text query matched against name, category, description." },
180
358
  },
181
359
  },
182
360
  },
183
361
  {
184
362
  name: "lumeo_get_component",
185
- description: "Get the reference for a single Lumeo component. " +
186
- "For the ~35 hand-curated components this returns full schema " +
187
- "(parameters, slots, Razor example, CSS variables). " +
188
- "For the remaining components, returns { name, category, description, files, cssVars, dependencies, note } with a link to the docs site.",
363
+ description: "Get the COMPLETE schema for a Lumeo component: every [Parameter] " +
364
+ "(name, type, default, XML doc summary), nested enums and records, " +
365
+ "EventCallback events, sub-components (e.g. Dialog DialogContent, " +
366
+ "DialogHeader, DialogTrigger, ...), CSS variables, source files, and a " +
367
+ "hand-curated Razor example when available. Sourced from the actual " +
368
+ "Razor source via Roslyn — always in sync with the library.",
189
369
  inputSchema: {
190
370
  type: "object",
191
371
  required: ["name"],
192
372
  properties: {
193
373
  name: {
194
374
  type: "string",
195
- description: "Component name (e.g. \"Button\", \"DataGrid\"). Case-insensitive.",
375
+ description: "Component name (e.g. \"Button\", \"DataGrid\", \"Sheet\"). Case-insensitive.",
196
376
  },
197
377
  },
198
378
  },
199
379
  },
200
380
  {
201
381
  name: "lumeo_search",
202
- description: `Fuzzy search across all ${catalog.length} Lumeo components (names, categories, descriptions). Returns best matches first.`,
382
+ description: `Fuzzy search across all ${components.length} Lumeo components (name, category, description). Best matches first.`,
203
383
  inputSchema: {
204
384
  type: "object",
205
385
  required: ["query"],
206
386
  properties: {
207
- query: {
208
- type: "string",
209
- description: "Search terms (e.g. \"modal\", \"date\", \"chat message\").",
210
- },
387
+ query: { type: "string", description: "Search terms (e.g. \"modal\", \"date\", \"chat message\")." },
388
+ },
389
+ },
390
+ },
391
+ {
392
+ name: "lumeo_get_example",
393
+ description: "Get working Razor example snippet(s) for a component — the exact code behind the docs-site demos. " +
394
+ "Returns an array of { title, code }. Use this before writing markup for an unfamiliar component.",
395
+ inputSchema: {
396
+ type: "object",
397
+ required: ["name"],
398
+ properties: {
399
+ name: { type: "string", description: "Component name (e.g. \"Tabs\", \"DataGrid\"). Case-insensitive." },
211
400
  },
212
401
  },
213
402
  },
403
+ {
404
+ name: "lumeo_get_install",
405
+ description: "Everything needed to actually USE a component: NuGet package + `dotnet add` line, `@using` imports, " +
406
+ "DI registration (builder.Services.AddLumeo…), host-page <script>/<link> includes, sub-components, and " +
407
+ "gotchas (portal components needing theme classes on <body>, OverlayProvider, required params).",
408
+ inputSchema: {
409
+ type: "object",
410
+ required: ["name"],
411
+ properties: {
412
+ name: { type: "string", description: "Component name. Case-insensitive." },
413
+ },
414
+ },
415
+ },
416
+ {
417
+ name: "lumeo_validate_markup",
418
+ description: "Validate a snippet of Razor that uses Lumeo components BEFORE compiling it. Checks: do the components exist? " +
419
+ "are parameter names valid (catches hallucinated APIs)? are enum values legal? are sub-components nested inside " +
420
+ "their required parent (e.g. <TabsContent> inside <Tabs>, <DialogContent> inside <Dialog>)? " +
421
+ "Returns { ok, issues: [{ severity, component, message }] }. Pass-through HTML/data-/aria- attributes are allowed.",
422
+ inputSchema: {
423
+ type: "object",
424
+ required: ["markup"],
425
+ properties: {
426
+ markup: { type: "string", description: "Razor markup to validate (the component/markup portion — @code blocks are ignored)." },
427
+ },
428
+ },
429
+ },
430
+ {
431
+ name: "lumeo_get_theme_tokens",
432
+ description: `List all ${themeTokens.length} Lumeo theme tokens (CSS custom properties driving colours + radii). ` +
433
+ "These are the ONLY colours you should use — as Tailwind-style utilities like `bg-primary`, `text-foreground`, " +
434
+ "`border-border`, `bg-card`, `text-muted-foreground`, `ring-ring`. Never raw hex/hsl, never `dark:` prefixes " +
435
+ "(dark mode is a CSS-variable swap). Returns { token, cssVar } pairs.",
436
+ inputSchema: { type: "object", properties: {} },
437
+ },
438
+ {
439
+ name: "lumeo_list_patterns",
440
+ description: `List all ${patterns.length} Lumeo "patterns" / "blocks" — full-page composed examples (dashboard, auth, chat, ` +
441
+ "kanban, mail, settings, …) built entirely from Lumeo components. Returns { title, key, route, description }. " +
442
+ "Use lumeo_get_pattern for the full Razor source of one.",
443
+ inputSchema: { type: "object", properties: {} },
444
+ },
445
+ {
446
+ name: "lumeo_get_pattern",
447
+ description: "Get the complete Razor source of a Lumeo pattern/block (a full-page composed example). Returns title, " +
448
+ "description, route, and the demo code snippet(s). Great as a starting skeleton for a real page.",
449
+ inputSchema: {
450
+ type: "object",
451
+ required: ["key"],
452
+ properties: {
453
+ key: { type: "string", description: "Pattern key (kebab-case, from lumeo_list_patterns) — e.g. \"dashboard\", \"authentication\", \"chat\"." },
454
+ },
455
+ },
456
+ },
457
+ {
458
+ name: "lumeo_changelog",
459
+ description: "Current Lumeo package version, when the API schema was generated, and a link to the full changelog. " +
460
+ "Use to check which version's API surface this MCP reflects.",
461
+ inputSchema: { type: "object", properties: {} },
462
+ },
214
463
  ],
215
464
  }));
216
465
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -221,9 +470,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
221
470
  const category = typeof a.category === "string" ? a.category : undefined;
222
471
  const query = typeof a.query === "string" ? a.query : "";
223
472
  const results = searchCatalog(query, category).map(toListPayload);
224
- return {
225
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
226
- };
473
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
227
474
  }
228
475
  case "lumeo_get_component": {
229
476
  const wanted = typeof a.name === "string" ? a.name : "";
@@ -237,48 +484,91 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
237
484
  }],
238
485
  };
239
486
  }
240
- const payload = c.thin
241
- ? {
242
- name: c.name,
243
- category: c.category,
244
- description: c.description,
245
- files: c.files,
246
- cssVars: c.cssVars,
247
- dependencies: c.dependencies,
248
- note: `Rich schema coming soon — see ${docsUrl(c)}`,
249
- }
250
- : {
251
- name: c.name,
252
- category: c.category,
253
- description: c.description,
254
- params: c.params,
255
- slots: c.slots,
256
- example: c.example,
257
- cssVars: c.cssVars,
258
- docs: docsUrl(c),
259
- };
260
- return {
261
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
262
- };
487
+ return { content: [{ type: "text", text: JSON.stringify(toGetPayload(c), null, 2) }] };
263
488
  }
264
489
  case "lumeo_search": {
265
490
  const query = typeof a.query === "string" ? a.query : "";
266
491
  const results = searchCatalog(query).map(toListPayload);
267
- return {
268
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
269
- };
492
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
493
+ }
494
+ case "lumeo_get_example": {
495
+ const wanted = typeof a.name === "string" ? a.name : "";
496
+ const c = findComponent(wanted);
497
+ if (!c) {
498
+ return { isError: true, content: [{ type: "text", text: `Component "${wanted}" not found. Use lumeo_search.` }] };
499
+ }
500
+ const examples = (c.examples ?? []).slice();
501
+ const curated = curatedExampleByName.get(c.name.toLowerCase());
502
+ if (curated && !examples.some((e) => e.code.trim() === curated.trim())) {
503
+ examples.push({ title: `${c.name} (curated)`, code: curated });
504
+ }
505
+ if (examples.length === 0) {
506
+ return { content: [{ type: "text", text: `No example on file for "${c.name}". See ${docsUrl(c)} or call lumeo_get_component for its full API.` }] };
507
+ }
508
+ return { content: [{ type: "text", text: JSON.stringify({ component: c.name, docs: docsUrl(c), examples }, null, 2) }] };
509
+ }
510
+ case "lumeo_get_install": {
511
+ const wanted = typeof a.name === "string" ? a.name : "";
512
+ const c = findComponent(wanted);
513
+ if (!c) {
514
+ return { isError: true, content: [{ type: "text", text: `Component "${wanted}" not found. Use lumeo_search.` }] };
515
+ }
516
+ return { content: [{ type: "text", text: JSON.stringify(buildInstallInfo(c), null, 2) }] };
517
+ }
518
+ case "lumeo_validate_markup": {
519
+ const markup = typeof a.markup === "string" ? a.markup : "";
520
+ if (!markup.trim()) {
521
+ return { isError: true, content: [{ type: "text", text: "No markup provided." }] };
522
+ }
523
+ const result = validateMarkup(markup);
524
+ const summary = result.ok
525
+ ? (result.issues.length ? `OK with ${result.issues.length} warning(s).` : "OK — no issues found.")
526
+ : `${result.issues.filter((i) => i.severity === "error").length} error(s), ${result.issues.filter((i) => i.severity === "warning").length} warning(s).`;
527
+ return { content: [{ type: "text", text: JSON.stringify({ ...result, summary }, null, 2) }] };
528
+ }
529
+ case "lumeo_get_theme_tokens": {
530
+ return { content: [{ type: "text", text: JSON.stringify({
531
+ count: themeTokens.length,
532
+ usage: "Use as Tailwind-style utilities: bg-{token}, text-{token}, border-{token}, ring-{token}, fill-{token}. e.g. `bg-primary text-primary-foreground`, `border-border/40`, `text-muted-foreground`. Radius tokens (radius, radius-sm, radius-lg, …) → `rounded-[var(--radius-lg)]`. Never raw hex; never `dark:` prefixes (dark mode swaps the variable values).",
533
+ tokens: themeTokens,
534
+ }, null, 2) }] };
535
+ }
536
+ case "lumeo_list_patterns": {
537
+ const list = patterns.map((p) => ({
538
+ title: p.title,
539
+ key: p.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
540
+ route: p.route,
541
+ description: p.description,
542
+ }));
543
+ return { content: [{ type: "text", text: JSON.stringify(list, null, 2) }] };
544
+ }
545
+ case "lumeo_get_pattern": {
546
+ const key = (typeof a.key === "string" ? a.key : "").toLowerCase();
547
+ const p = patternByKey.get(key) ?? patterns.find((x) => x.title.toLowerCase().includes(key) || x.route.includes(key));
548
+ if (!p) {
549
+ return { isError: true, content: [{ type: "text", text: `Pattern "${key}" not found. Use lumeo_list_patterns for valid keys.` }] };
550
+ }
551
+ return { content: [{ type: "text", text: JSON.stringify({
552
+ title: p.title, route: `${DOCS_BASE}${p.route}`, description: p.description, examples: p.examples,
553
+ }, null, 2) }] };
554
+ }
555
+ case "lumeo_changelog": {
556
+ return { content: [{ type: "text", text: JSON.stringify({
557
+ version: api.version,
558
+ apiSchemaGenerated: api.generated,
559
+ componentCount: api.stats.componentCount,
560
+ changelog: `${DOCS_BASE}/docs/changelog`,
561
+ note: "Detailed per-release notes live on the docs site. This MCP's API surface reflects the version above.",
562
+ }, null, 2) }] };
270
563
  }
271
564
  default:
272
- return {
273
- isError: true,
274
- content: [{ type: "text", text: `Unknown tool: ${name}` }],
275
- };
565
+ return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] };
276
566
  }
277
567
  });
278
568
  // ───── Resources ─────
279
569
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
280
570
  resources: [
281
- ...catalog.map((c) => ({
571
+ ...components.map((c) => ({
282
572
  uri: `lumeo://component/${c.name}`,
283
573
  name: `${c.name} (Lumeo component)`,
284
574
  description: c.description,
@@ -297,7 +587,7 @@ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
297
587
  {
298
588
  uriTemplate: "lumeo://component/{name}",
299
589
  name: "Lumeo component reference",
300
- description: "Markdown reference for a single Lumeo component. Rich (params/slots/example) for curated components, thin (files/cssVars + docs link) otherwise.",
590
+ description: "Markdown reference for a single Lumeo component, generated from Razor source.",
301
591
  mimeType: "text/markdown",
302
592
  },
303
593
  {
@@ -312,29 +602,16 @@ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
312
602
  const uri = req.params.uri;
313
603
  const componentMatch = /^lumeo:\/\/component\/(.+)$/i.exec(uri);
314
604
  if (componentMatch) {
315
- const name = decodeURIComponent(componentMatch[1]);
316
- const c = findComponent(name);
317
- if (!c) {
318
- throw new Error(`Unknown Lumeo component: ${name}`);
319
- }
320
- return {
321
- contents: [{
322
- uri,
323
- mimeType: "text/markdown",
324
- text: toComponentMarkdown(c),
325
- }],
326
- };
605
+ const wanted = decodeURIComponent(componentMatch[1]);
606
+ const c = findComponent(wanted);
607
+ if (!c)
608
+ throw new Error(`Unknown Lumeo component: ${wanted}`);
609
+ return { contents: [{ uri, mimeType: "text/markdown", text: toComponentMarkdown(c) }] };
327
610
  }
328
611
  const categoryMatch = /^lumeo:\/\/category\/(.+)$/i.exec(uri);
329
612
  if (categoryMatch) {
330
613
  const cat = decodeURIComponent(categoryMatch[1]);
331
- return {
332
- contents: [{
333
- uri,
334
- mimeType: "text/markdown",
335
- text: toCategoryMarkdown(cat),
336
- }],
337
- };
614
+ return { contents: [{ uri, mimeType: "text/markdown", text: toCategoryMarkdown(cat) }] };
338
615
  }
339
616
  throw new Error(`Unsupported resource URI: ${uri}`);
340
617
  });
@@ -342,14 +619,10 @@ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
342
619
  async function main() {
343
620
  const transport = new StdioServerTransport();
344
621
  await server.connect(transport);
345
- // stderr is safe; stdout is the MCP transport
346
- const richCount = catalog.filter((c) => !c.thin).length;
347
- const thinCount = catalog.length - richCount;
348
- const registryNote = registry
349
- ? `, registry v${registry.version}`
350
- : " (no registry — curated-only mode)";
351
- process.stderr.write(`[lumeo-mcp] ready — ${catalog.length} components, ${CATEGORIES.length} categories ` +
352
- `(${richCount} rich, ${thinCount} thin)${registryNote}\n`);
622
+ process.stderr.write(`[lumeo-mcp] ready ${components.length} components, ${CATEGORIES.length} categories, ` +
623
+ `${api.stats.totalParameters} params, ${api.stats.totalEnums} enums, ` +
624
+ `${themeTokens.length} theme tokens, ${patterns.length} patterns, ` +
625
+ `api v${api.version}, generated ${api.generated}\n`);
353
626
  }
354
627
  main().catch((err) => {
355
628
  process.stderr.write(`[lumeo-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);