@lumeo-ui/mcp-server 2.0.0-rc.4 → 2.0.0-rc.41
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/componentsApi.js +46 -0
- package/dist/index.js +433 -160
- package/dist/installInfo.js +101 -0
- package/package.json +2 -1
- package/src/components-api.json +40614 -0
- package/src/registry.json +1265 -15
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
* - lumeo_get_component —
|
|
12
|
-
* - lumeo_search — fuzzy
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
64
|
-
const paramRows = c.
|
|
65
|
-
|
|
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
|
|
68
|
-
.map((
|
|
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
|
-
?
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
]
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 =
|
|
127
|
-
if (inCat.length === 0)
|
|
128
|
-
return `# ${category}\n\nNo components
|
|
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.
|
|
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 ${
|
|
167
|
-
"Returns
|
|
168
|
-
`
|
|
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
|
-
|
|
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
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"
|
|
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 ${
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
|
316
|
-
const c = findComponent(
|
|
317
|
-
if (!c)
|
|
318
|
-
throw new Error(`Unknown Lumeo component: ${
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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`);
|