@lumeo-ui/mcp-server 3.19.0 → 4.0.1
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/README.md +32 -20
- package/dist/components.js +11 -9
- package/dist/componentsApi.js +1 -1
- package/dist/index.js +106 -95
- package/dist/registry.js +2 -1
- package/dist/validate.js +216 -0
- package/package.json +4 -2
- package/src/components-api.json +11488 -4201
- package/src/registry.json +2796 -232
package/README.md
CHANGED
|
@@ -11,18 +11,29 @@ actually compiles.
|
|
|
11
11
|
|
|
12
12
|
## What it exposes
|
|
13
13
|
|
|
14
|
-
The server covers **
|
|
15
|
-
`registry.json
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
The server covers **the full Lumeo component catalog** from the generated
|
|
15
|
+
`registry.json` + `components-api.json`. Every component is returned with its
|
|
16
|
+
parameters (including which are `[EditorRequired]`), enums, sub-components, CSS
|
|
17
|
+
variables, examples, its **test-coverage tier**, and its **accessibility contract**
|
|
18
|
+
(roles, keyboard keys, focus) — the schema is source-derived (Roslyn) so it has full
|
|
19
|
+
coverage with no thin fallbacks, and stays in lockstep with each release.
|
|
20
20
|
|
|
21
21
|
### Tools
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
- `
|
|
25
|
-
- `
|
|
23
|
+
Components:
|
|
24
|
+
- `lumeo_list_components({ category?, query? })` — list all components (name, category, description)
|
|
25
|
+
- `lumeo_get_component({ name })` — full schema: every `[Parameter]` (with `required` for `[EditorRequired]`), enums, records, events, sub-components, CSS vars, source files, a curated example, and the component's **test-coverage tier**
|
|
26
|
+
- `lumeo_get_a11y({ name })` — accessibility contract: ARIA roles + `aria-*` attributes rendered, keyboard keys handled, focus management, and whether that behaviour is test-covered
|
|
27
|
+
- `lumeo_get_install({ name })` — install + setup: NuGet package, `using`s, DI, host includes, required parameters, portal/overlay requirements
|
|
28
|
+
- `lumeo_get_example({ name })` — a hand-curated Razor usage example
|
|
29
|
+
- `lumeo_search({ query })` — fuzzy search across components and services
|
|
30
|
+
- `lumeo_validate_markup({ markup })` — static-check Razor: unknown params, **type-bound enum-value validation** (e.g. `Size="Large"` is rejected), and missing-parent errors for sub-components that read a cascading context
|
|
31
|
+
|
|
32
|
+
Services, patterns, theme:
|
|
33
|
+
- `lumeo_list_services()` / `lumeo_get_service({ name })` — service-layer API (OverlayService, ThemeService, global enums, …)
|
|
34
|
+
- `lumeo_list_patterns()` / `lumeo_get_pattern({ name })` — higher-level composition patterns
|
|
35
|
+
- `lumeo_get_theme_tokens()` — design tokens ↔ CSS variables
|
|
36
|
+
- `lumeo_changelog()` — recent library changes
|
|
26
37
|
|
|
27
38
|
### Resources
|
|
28
39
|
|
|
@@ -52,7 +63,7 @@ Edit `claude_desktop_config.json` (Settings → Developer → Edit Config):
|
|
|
52
63
|
"mcpServers": {
|
|
53
64
|
"lumeo": {
|
|
54
65
|
"command": "node",
|
|
55
|
-
"args": ["
|
|
66
|
+
"args": ["/absolute/path/to/Lumeo/tools/lumeo-mcp/dist/index.js"]
|
|
56
67
|
}
|
|
57
68
|
}
|
|
58
69
|
}
|
|
@@ -113,16 +124,17 @@ npm run dev # tsc --watch
|
|
|
113
124
|
npm start # run the built server
|
|
114
125
|
```
|
|
115
126
|
|
|
116
|
-
The component
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
The component schema is generated at build time, not hand-maintained:
|
|
128
|
+
`tools/Lumeo.RegistryGen` reads the actual Razor source via Roslyn and emits full
|
|
129
|
+
params / enums / events / sub-component metadata for every component into
|
|
130
|
+
`src/Lumeo/registry/`. `scripts/sync-registry.mjs` copies the generated
|
|
131
|
+
`registry.json` (164 components) and `components-api.json` here at `prebuild`
|
|
132
|
+
time, so the catalog never drifts from the source.
|
|
133
|
+
|
|
134
|
+
`src/components.ts` only layers a few extra hand-curated example snippets on top —
|
|
135
|
+
there is no thin/rich split and no manual catalog drift. To add a richer example
|
|
136
|
+
for a component, add an entry for it in `src/components.ts`; the merge layer
|
|
137
|
+
overlays it onto the generated schema.
|
|
126
138
|
|
|
127
139
|
## License
|
|
128
140
|
|
package/dist/components.js
CHANGED
|
@@ -317,14 +317,16 @@ export const components = [
|
|
|
317
317
|
{ name: "ChildContent", description: "DialogHeader / DialogContent / DialogFooter." },
|
|
318
318
|
],
|
|
319
319
|
example: `<Dialog @bind-Open="_dialogOpen">
|
|
320
|
-
<
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
320
|
+
<DialogContent>
|
|
321
|
+
<DialogHeader>
|
|
322
|
+
<DialogTitle>Are you sure?</DialogTitle>
|
|
323
|
+
<DialogDescription>This cannot be undone.</DialogDescription>
|
|
324
|
+
</DialogHeader>
|
|
325
|
+
<DialogFooter>
|
|
326
|
+
<Button Variant="Button.ButtonVariant.Outline" OnClick='() => _dialogOpen = false'>Cancel</Button>
|
|
327
|
+
<Button Variant="Button.ButtonVariant.Destructive" OnClick="Confirm">Delete</Button>
|
|
328
|
+
</DialogFooter>
|
|
329
|
+
</DialogContent>
|
|
328
330
|
</Dialog>`,
|
|
329
331
|
cssVars: ["--color-background", "--color-foreground", "--color-border"],
|
|
330
332
|
},
|
|
@@ -676,7 +678,7 @@ function slugify(name) {
|
|
|
676
678
|
export const registry = loadRegistry();
|
|
677
679
|
/**
|
|
678
680
|
* Build the unified catalog:
|
|
679
|
-
* -
|
|
681
|
+
* - Every component from the registry
|
|
680
682
|
* - Hand-curated rich entries override the thin registry entries for their name
|
|
681
683
|
*/
|
|
682
684
|
function buildCatalog() {
|
package/dist/componentsApi.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `tools/Lumeo.RegistryGen` (Roslyn-based scan of every `[Parameter]` /
|
|
4
4
|
* `[CascadingParameter]` property across every Razor component in the repo).
|
|
5
5
|
*
|
|
6
|
-
* This is the source-of-truth schema for ALL
|
|
6
|
+
* This is the source-of-truth schema for ALL Lumeo components. The
|
|
7
7
|
* legacy hand-curated `components.ts` is kept as an OPTIONAL example overlay:
|
|
8
8
|
* when it has an entry for a component we surface its `example` Razor snippet
|
|
9
9
|
* verbatim alongside the auto-generated parameter list.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Lumeo MCP Server
|
|
3
|
+
* Lumeo MCP Server
|
|
4
|
+
*
|
|
5
|
+
* (Runtime version is read from package.json at startup — see SERVER_VERSION below —
|
|
6
|
+
* so it never drifts from the published package.)
|
|
4
7
|
*
|
|
5
8
|
* Source-of-truth schema for ALL Lumeo components, generated at build time
|
|
6
9
|
* by `tools/Lumeo.RegistryGen` from the actual Razor source via Roslyn. Every
|
|
@@ -8,7 +11,7 @@
|
|
|
8
11
|
* no thin/rich split, no manual catalog drift.
|
|
9
12
|
*
|
|
10
13
|
* Tools:
|
|
11
|
-
* - lumeo_list_components — list/filter all
|
|
14
|
+
* - lumeo_list_components — list/filter all components (name+category+description)
|
|
12
15
|
* - lumeo_get_component — full schema (params, enums, events, sub-components, files, examples)
|
|
13
16
|
* - lumeo_search — fuzzy search across name/category/description
|
|
14
17
|
* - lumeo_get_example — working Razor snippet(s) for a component
|
|
@@ -21,6 +24,7 @@
|
|
|
21
24
|
* Resources (URI template):
|
|
22
25
|
* - lumeo://component/{name} — markdown reference per component
|
|
23
26
|
* - lumeo://category/{name} — overview of all components in a category
|
|
27
|
+
* - lumeo://service/{name} — markdown reference per injectable service
|
|
24
28
|
*
|
|
25
29
|
* Transport: stdio (the standard for spawned MCP servers).
|
|
26
30
|
*/
|
|
@@ -29,8 +33,24 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
29
33
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
30
34
|
import { loadComponentsApi, } from "./componentsApi.js";
|
|
31
35
|
import { components as curatedExamples } from "./components.js";
|
|
36
|
+
import { loadRegistry } from "./registry.js";
|
|
32
37
|
import { setupFor, PORTAL_COMPONENTS, NEEDS_OVERLAY_PROVIDER } from "./installInfo.js";
|
|
38
|
+
import { createValidator } from "./validate.js";
|
|
39
|
+
import { readFileSync } from "node:fs";
|
|
33
40
|
const DOCS_BASE = "https://lumeo.nativ.sh";
|
|
41
|
+
// Server version, read from package.json so it can never drift from the published
|
|
42
|
+
// package — the publish job bumps package.json via `npm version`, and this file no
|
|
43
|
+
// longer carries a hand-edited literal that goes stale (it sat at 2.2.3 while the
|
|
44
|
+
// library was 3.19.0).
|
|
45
|
+
const SERVER_VERSION = (() => {
|
|
46
|
+
try {
|
|
47
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
48
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "0.0.0";
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
34
54
|
// ───────────────── Load source-of-truth schema ─────────────────
|
|
35
55
|
const api = loadComponentsApi() ?? {
|
|
36
56
|
version: "0.0.0",
|
|
@@ -68,6 +88,24 @@ const patternByKey = new Map(patterns.map((p) => [p.title.toLowerCase().replace(
|
|
|
68
88
|
// parameters/enums/events; the curated `example` Razor snippet is preserved
|
|
69
89
|
// as a documentation aid because LLMs benefit from seeing real usage.
|
|
70
90
|
const curatedExampleByName = new Map(curatedExamples.map((c) => [c.name.toLowerCase(), c.example]));
|
|
91
|
+
// Per-component test-coverage (tier + which test dimensions exist), from registry.json.
|
|
92
|
+
// Surfaced in get_component so an agent can see, at a glance, what's battle-tested.
|
|
93
|
+
const coverageByName = new Map((loadRegistry()?.components ?? [])
|
|
94
|
+
.filter((c) => c.testCoverage)
|
|
95
|
+
.map((c) => [c.name.toLowerCase(), c.testCoverage]));
|
|
96
|
+
/** One-line human summary of a component's test coverage, e.g.
|
|
97
|
+
* "tier 3/4 · 32 tests · render, behavior, a11y, keyboard". */
|
|
98
|
+
function coverageSummary(tc) {
|
|
99
|
+
const dims = [
|
|
100
|
+
tc.render && "render",
|
|
101
|
+
tc.behavior && "behavior",
|
|
102
|
+
tc.a11y && "a11y",
|
|
103
|
+
tc.keyboard && "keyboard",
|
|
104
|
+
tc.scale && "scale",
|
|
105
|
+
tc.e2e && "e2e",
|
|
106
|
+
].filter(Boolean);
|
|
107
|
+
return `tier ${tc.tier}/4 · ${tc.tests} test${tc.tests === 1 ? "" : "s"}${dims.length ? ` · ${dims.join(", ")}` : ""}`;
|
|
108
|
+
}
|
|
71
109
|
// ───────────────── Helpers ─────────────────
|
|
72
110
|
function findComponent(name) {
|
|
73
111
|
// Top-level match first; fall back to the parent of a matching sub-component
|
|
@@ -125,6 +163,8 @@ function paramRow(p) {
|
|
|
125
163
|
const def = p.default ?? "—";
|
|
126
164
|
const desc = p.description ?? "";
|
|
127
165
|
const flags = [];
|
|
166
|
+
if (p.isEditorRequired)
|
|
167
|
+
flags.push("required");
|
|
128
168
|
if (p.isCascading)
|
|
129
169
|
flags.push("cascading");
|
|
130
170
|
if (p.captureUnmatched)
|
|
@@ -147,12 +187,14 @@ function toComponentMarkdown(c) {
|
|
|
147
187
|
? c.files.map((f) => `- \`${f}\``).join("\n")
|
|
148
188
|
: "";
|
|
149
189
|
const example = curatedExampleByName.get(c.name.toLowerCase());
|
|
190
|
+
const coverage = coverageByName.get(c.name.toLowerCase());
|
|
150
191
|
const sections = [
|
|
151
192
|
`# ${c.name}`,
|
|
152
193
|
"",
|
|
153
194
|
`**Category:** ${c.category}${c.subcategory ? ` › ${c.subcategory}` : ""}`,
|
|
154
195
|
`**NuGet:** \`${c.nugetPackage}\``,
|
|
155
196
|
`**Namespace:** \`${c.namespace ?? "Lumeo"}\``,
|
|
197
|
+
...(coverage ? [`**Test coverage:** ${coverageSummary(coverage)}`] : []),
|
|
156
198
|
"",
|
|
157
199
|
c.description,
|
|
158
200
|
"",
|
|
@@ -326,6 +368,24 @@ function toGetPayload(c) {
|
|
|
326
368
|
subComponents,
|
|
327
369
|
examples: c.examples ?? [],
|
|
328
370
|
curatedExample: curatedExampleByName.get(c.name.toLowerCase()) ?? null,
|
|
371
|
+
testCoverage: coverageByName.get(c.name.toLowerCase()) ?? null,
|
|
372
|
+
docs: docsUrl(c),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// ───────────────── lumeo_get_a11y ─────────────────
|
|
376
|
+
function toA11yPayload(c) {
|
|
377
|
+
const a11y = c.a11y ?? { roles: [], ariaAttributes: [], keys: [], keyboardInteractive: false, focusManaged: false };
|
|
378
|
+
const cov = coverageByName.get(c.name.toLowerCase());
|
|
379
|
+
return {
|
|
380
|
+
name: c.name,
|
|
381
|
+
roles: a11y.roles,
|
|
382
|
+
ariaAttributes: a11y.ariaAttributes,
|
|
383
|
+
keyboardKeys: a11y.keys,
|
|
384
|
+
keyboardInteractive: a11y.keyboardInteractive,
|
|
385
|
+
focusManaged: a11y.focusManaged,
|
|
386
|
+
// Whether the a11y / keyboard behaviour is actually covered by tests.
|
|
387
|
+
tested: cov ? { a11y: cov.a11y, keyboard: cov.keyboard } : null,
|
|
388
|
+
gotchas: c.gotchas ?? [],
|
|
329
389
|
docs: docsUrl(c),
|
|
330
390
|
};
|
|
331
391
|
}
|
|
@@ -335,8 +395,9 @@ function buildInstallInfo(c) {
|
|
|
335
395
|
const subNames = Object.values(c.subComponents).map((s) => s.componentName);
|
|
336
396
|
const isPortal = PORTAL_COMPONENTS.has(c.name);
|
|
337
397
|
const needsOverlayProvider = NEEDS_OVERLAY_PROVIDER.has(c.name);
|
|
398
|
+
// [EditorRequired] params, from the real scanner flag (was a fragile description-text grep).
|
|
338
399
|
const requiredParams = c.parameters
|
|
339
|
-
.filter((p) =>
|
|
400
|
+
.filter((p) => p.isEditorRequired)
|
|
340
401
|
.map((p) => p.name);
|
|
341
402
|
return {
|
|
342
403
|
component: c.name,
|
|
@@ -368,101 +429,19 @@ function buildInstallInfo(c) {
|
|
|
368
429
|
docs: docsUrl(c),
|
|
369
430
|
};
|
|
370
431
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
m.set(c.name.toLowerCase(), { params: new Set(c.parameters.map((p) => p.name)), enums });
|
|
381
|
-
for (const s of Object.values(c.subComponents)) {
|
|
382
|
-
const subEnums = new Map();
|
|
383
|
-
for (const e of s.enums)
|
|
384
|
-
subEnums.set(e.name, enumValueSet(e.values));
|
|
385
|
-
m.set(s.componentName.toLowerCase(), { params: new Set(s.parameters.map((p) => p.name)), enums: subEnums, parent: c.name });
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
return m;
|
|
389
|
-
})();
|
|
390
|
-
function validateMarkup(markup) {
|
|
391
|
-
const issues = [];
|
|
392
|
-
// Find component tags: <Foo ...> or <Foo .../> or </Foo>. Lumeo components are PascalCase.
|
|
393
|
-
const tagRx = /<\/?([A-Z][A-Za-z0-9]*)((?:\s+[^<>]*?)?)\/?>/g;
|
|
394
|
-
// Track which known Lumeo components appear, to validate parent-child.
|
|
395
|
-
const present = new Set();
|
|
396
|
-
let m;
|
|
397
|
-
while ((m = tagRx.exec(markup)) !== null) {
|
|
398
|
-
const tag = m[1];
|
|
399
|
-
const isClose = m[0].startsWith("</");
|
|
400
|
-
const known = elementIndex.get(tag.toLowerCase());
|
|
401
|
-
if (!known)
|
|
402
|
-
continue; // not a Lumeo element (could be a user component / HTML — ignore)
|
|
403
|
-
present.add(tag);
|
|
404
|
-
if (isClose)
|
|
405
|
-
continue;
|
|
406
|
-
// Parse attributes (best-effort): Name="..." | Name='...' | Name="@expr" | Name=@expr | bare-name
|
|
407
|
-
const attrBlob = m[2] ?? "";
|
|
408
|
-
const attrRx = /([@A-Za-z_][\w-]*)\s*=\s*("(?:[^"]*)"|'(?:[^']*)'|@?[^\s"'<>]+)|([@A-Za-z_][\w-]*)(?=\s|$)/g;
|
|
409
|
-
let am;
|
|
410
|
-
while ((am = attrRx.exec(attrBlob)) !== null) {
|
|
411
|
-
let name = (am[1] ?? am[3] ?? "").trim();
|
|
412
|
-
if (!name)
|
|
413
|
-
continue;
|
|
414
|
-
// Strip Blazor directive prefixes/suffixes: @bind-Foo, @bind-Foo:event, Foo:stopPropagation, @onclick, @attributes, @key, @ref, @bind
|
|
415
|
-
if (name.startsWith("@bind-"))
|
|
416
|
-
name = name.slice("@bind-".length).split(":")[0];
|
|
417
|
-
else if (name.startsWith("@bind"))
|
|
418
|
-
continue; // @bind / @bind:event on inputs — skip
|
|
419
|
-
else if (name.startsWith("@on") || name === "@attributes" || name === "@key" || name === "@ref" || name === "@oninput" || name === "@onchange")
|
|
420
|
-
continue;
|
|
421
|
-
else if (name.startsWith("@"))
|
|
422
|
-
continue; // other directives
|
|
423
|
-
if (name.includes(":"))
|
|
424
|
-
name = name.split(":")[0]; // EventName:stopPropagation, :preventDefault
|
|
425
|
-
if (name === "class" || name === "style" || name === "id")
|
|
426
|
-
continue; // pass-through HTML attrs (Lumeo captures unmatched)
|
|
427
|
-
if (/^data-|^aria-/i.test(name))
|
|
428
|
-
continue; // captured unmatched
|
|
429
|
-
if (!known.params.has(name)) {
|
|
430
|
-
// Could be a captured-unmatched HTML attr — only flag if it looks like a typo'd Lumeo param (PascalCase).
|
|
431
|
-
if (/^[A-Z]/.test(name)) {
|
|
432
|
-
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.)` });
|
|
433
|
-
}
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
// Enum value check: Foo="Bar.Baz.Qux" or Foo="Qux"
|
|
437
|
-
const rawVal = (am[2] ?? "").replace(/^["']|["']$/g, "").trim();
|
|
438
|
-
if (!rawVal || rawVal.startsWith("@"))
|
|
439
|
-
continue; // dynamic expression — can't statically check
|
|
440
|
-
// Which enum does this param use? Match by param name heuristically against enum names.
|
|
441
|
-
for (const [enumName, vals] of known.enums) {
|
|
442
|
-
// crude: the param likely uses this enum if rawVal looks like EnumName.Value or matches a value
|
|
443
|
-
const lastSeg = rawVal.split(".").pop().toLowerCase();
|
|
444
|
-
const looksLikeThisEnum = rawVal.toLowerCase().includes(enumName.toLowerCase()) || vals.has(lastSeg);
|
|
445
|
-
if (looksLikeThisEnum && !vals.has(lastSeg)) {
|
|
446
|
-
issues.push({ severity: "error", component: tag, message: `\`${name}="${rawVal}"\` — \`${lastSeg}\` is not a valid ${enumName} value. Allowed: ${[...vals].join(", ")}.` });
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
// Parent-child: every sub-component present should have its required parent present somewhere.
|
|
452
|
-
for (const tag of present) {
|
|
453
|
-
const known = elementIndex.get(tag.toLowerCase());
|
|
454
|
-
if (known.parent && !present.has(known.parent)) {
|
|
455
|
-
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.` });
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
// Also flag obviously-unknown PascalCase tags that look like attempted Lumeo components.
|
|
459
|
-
// (Skip — too noisy; user components are also PascalCase. The known-element checks above are enough.)
|
|
460
|
-
return { ok: !issues.some((i) => i.severity === "error"), issues };
|
|
461
|
-
}
|
|
432
|
+
// ───────────────── lumeo_validate_markup ─────────────────
|
|
433
|
+
// The validator lives in ./validate.ts so it can be unit-tested in isolation;
|
|
434
|
+
// build it from the loaded catalog here. Shared/global enums (Lumeo.Size,
|
|
435
|
+
// Orientation, …) live in the service layer rather than on any one component, so
|
|
436
|
+
// feed them in separately for type-bound enum validation.
|
|
437
|
+
const sharedEnums = services
|
|
438
|
+
.filter((s) => s.kind === "enum")
|
|
439
|
+
.map((s) => ({ name: s.name, values: s.enumValues.map((v) => v.name) }));
|
|
440
|
+
const validateMarkup = createValidator(components, sharedEnums);
|
|
462
441
|
// ───────────────── Server setup ─────────────────
|
|
463
442
|
const server = new Server({
|
|
464
443
|
name: "lumeo-mcp",
|
|
465
|
-
version:
|
|
444
|
+
version: SERVER_VERSION,
|
|
466
445
|
}, {
|
|
467
446
|
capabilities: {
|
|
468
447
|
tools: {},
|
|
@@ -504,6 +483,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
504
483
|
},
|
|
505
484
|
},
|
|
506
485
|
},
|
|
486
|
+
{
|
|
487
|
+
name: "lumeo_get_a11y",
|
|
488
|
+
description: "Get the ACCESSIBILITY contract for a Lumeo component: the ARIA roles and aria-* " +
|
|
489
|
+
"attributes it renders, the keyboard keys it handles, whether it manages focus, and " +
|
|
490
|
+
"whether that a11y/keyboard behaviour is covered by tests. Use it to verify a " +
|
|
491
|
+
"component's accessibility before relying on it, or to know which keys to exercise. " +
|
|
492
|
+
"Extracted from the actual Razor source by RegistryGen.",
|
|
493
|
+
inputSchema: {
|
|
494
|
+
type: "object",
|
|
495
|
+
required: ["name"],
|
|
496
|
+
properties: {
|
|
497
|
+
name: {
|
|
498
|
+
type: "string",
|
|
499
|
+
description: "Component name (e.g. \"Dialog\", \"Tabs\", \"Select\"). Case-insensitive.",
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
},
|
|
507
504
|
{
|
|
508
505
|
name: "lumeo_list_services",
|
|
509
506
|
description: `List all ${services.length} Lumeo SERVICE-LAYER public API types — services (OverlayService, ` +
|
|
@@ -641,6 +638,20 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
641
638
|
}
|
|
642
639
|
return { content: [{ type: "text", text: JSON.stringify(toGetPayload(c), null, 2) }] };
|
|
643
640
|
}
|
|
641
|
+
case "lumeo_get_a11y": {
|
|
642
|
+
const wanted = typeof a.name === "string" ? a.name : "";
|
|
643
|
+
const c = findComponent(wanted);
|
|
644
|
+
if (!c) {
|
|
645
|
+
return {
|
|
646
|
+
isError: true,
|
|
647
|
+
content: [{
|
|
648
|
+
type: "text",
|
|
649
|
+
text: `Component "${wanted}" not found. Use lumeo_list_components or lumeo_search to discover available components.`,
|
|
650
|
+
}],
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
return { content: [{ type: "text", text: JSON.stringify(toA11yPayload(c), null, 2) }] };
|
|
654
|
+
}
|
|
644
655
|
case "lumeo_list_services": {
|
|
645
656
|
const results = services.map(toServiceListPayload);
|
|
646
657
|
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
package/dist/registry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Loads Lumeo's generated registry.json (synced into src/registry.json at
|
|
3
|
-
* prebuild time — see `scripts/sync-registry.mjs`). All
|
|
3
|
+
* prebuild time — see `scripts/sync-registry.mjs`). All components are
|
|
4
4
|
* surfaced to the MCP server through this file so `lumeo_list_components`,
|
|
5
5
|
* `lumeo_get_component`, and `lumeo_search` can cover the full catalog.
|
|
6
6
|
*
|
|
@@ -71,6 +71,7 @@ export function loadRegistry() {
|
|
|
71
71
|
dependencies: entry.dependencies ?? [],
|
|
72
72
|
cssVars: entry.cssVars ?? [],
|
|
73
73
|
registryUrl: entry.registryUrl,
|
|
74
|
+
testCoverage: entry.testCoverage,
|
|
74
75
|
});
|
|
75
76
|
}
|
|
76
77
|
return {
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Markup validator for lumeo_validate_markup — extracted from index.ts so it can
|
|
2
|
+
// be unit-tested in isolation. Parameterized by the component catalog (rather than
|
|
3
|
+
// reaching into module-level state) so tests can drive it with a small synthetic
|
|
4
|
+
// catalog and the server can build it from the loaded components-api.
|
|
5
|
+
/** `@typeparam` names used by Lumeo's generic components, keyed by component name (lowercase)
|
|
6
|
+
* -> the exact type-param name(s) THAT component declares. These bind a generic component to
|
|
7
|
+
* a concrete type via a Razor attribute (e.g. `<DataGrid TItem="Employee">`), but they are NOT
|
|
8
|
+
* `[Parameter]` properties — the generated component catalog has no entry for them on any
|
|
9
|
+
* component. Scoped per-component (Codex P3) rather than a blanket name exemption: many Lumeo
|
|
10
|
+
* components capture unmatched attributes, so an unscoped exemption let genuine PascalCase
|
|
11
|
+
* typos like `<Button TItem="Order">` or `<Input T="Foo">` — exactly the class of mistake the
|
|
12
|
+
* validator exists to catch — through silently on ANY component, not just the generic ones
|
|
13
|
+
* that actually declare that type parameter. Derived from every `@typeparam` directive in the
|
|
14
|
+
* library (`grep -rn "@typeparam" src/Lumeo.* src/Lumeo`); not threaded through the registry schema for
|
|
15
|
+
* the same proportionality reason as the original (unscoped) fix — the set is small and stable. */
|
|
16
|
+
const GENERIC_TYPE_PARAMS_BY_COMPONENT = {
|
|
17
|
+
form: new Set(["TModel"]),
|
|
18
|
+
picklist: new Set(["TItem"]),
|
|
19
|
+
pivotgrid: new Set(["TItem"]),
|
|
20
|
+
sortablelist: new Set(["TItem"]),
|
|
21
|
+
taginput: new Set(["TItem"]),
|
|
22
|
+
treeview: new Set(["T"]),
|
|
23
|
+
treeviewnode: new Set(["T"]),
|
|
24
|
+
datagrid: new Set(["TItem"]),
|
|
25
|
+
datatable: new Set(["TItem"]),
|
|
26
|
+
datagridbody: new Set(["TItem"]),
|
|
27
|
+
datagridcell: new Set(["TItem"]),
|
|
28
|
+
datagridcolumndef: new Set(["TItem"]),
|
|
29
|
+
datagridcolumnfilter: new Set(["TItem"]),
|
|
30
|
+
datagridcolumngroup: new Set(["TItem"]),
|
|
31
|
+
datagridcolumnvisibility: new Set(["TItem"]),
|
|
32
|
+
datagriddetailrow: new Set(["TItem"]),
|
|
33
|
+
datagridfooter: new Set(["TItem"]),
|
|
34
|
+
datagridgrouprow: new Set(["TItem"]),
|
|
35
|
+
datagridheader: new Set(["TItem"]),
|
|
36
|
+
datagridheadercell: new Set(["TItem"]),
|
|
37
|
+
datagridpagination: new Set(["TItem"]),
|
|
38
|
+
datagridrow: new Set(["TItem"]),
|
|
39
|
+
datagridtoolbar: new Set(["TItem"]),
|
|
40
|
+
datagridtoolbarcolumns: new Set(["TItem"]),
|
|
41
|
+
datagridtoolbarcopyselected: new Set(["TItem"]),
|
|
42
|
+
datagridtoolbarexport: new Set(["TItem"]),
|
|
43
|
+
datagridtoolbarfullscreen: new Set(["TItem"]),
|
|
44
|
+
datagridtoolbarlayouts: new Set(["TItem"]),
|
|
45
|
+
toolbarcontent: new Set(["TItem"]),
|
|
46
|
+
};
|
|
47
|
+
/** Strips namespace / global:: / trailing nullable `?` to the bare type name:
|
|
48
|
+
* "Lumeo.Size?" → "Size", "global::Lumeo.Orientation" → "Orientation". */
|
|
49
|
+
function bareTypeName(type) {
|
|
50
|
+
if (!type)
|
|
51
|
+
return "";
|
|
52
|
+
return type.replace(/\?+$/, "").replace(/^global::/, "").split(".").pop() ?? "";
|
|
53
|
+
}
|
|
54
|
+
/** Global enum table: bare enum name (lowercased) → its display values. Built once from
|
|
55
|
+
* every component's `enums` plus the shared enums (Size, Orientation, …). The validator
|
|
56
|
+
* binds a param's declared `type` to one of these by name, so it checks a value against
|
|
57
|
+
* the param's ACTUAL enum rather than guessing from substring matches. */
|
|
58
|
+
function buildEnumTable(components, sharedEnums) {
|
|
59
|
+
const table = new Map();
|
|
60
|
+
const add = (name, values) => {
|
|
61
|
+
if (name && values.length && !table.has(name.toLowerCase()))
|
|
62
|
+
table.set(name.toLowerCase(), values);
|
|
63
|
+
};
|
|
64
|
+
for (const e of sharedEnums)
|
|
65
|
+
add(e.name, e.values);
|
|
66
|
+
for (const c of components) {
|
|
67
|
+
for (const e of c.enums)
|
|
68
|
+
add(e.name, e.values);
|
|
69
|
+
for (const s of Object.values(c.subComponents))
|
|
70
|
+
for (const e of s.enums)
|
|
71
|
+
add(e.name, e.values);
|
|
72
|
+
}
|
|
73
|
+
return table;
|
|
74
|
+
}
|
|
75
|
+
function buildElementIndex(components, enumTable) {
|
|
76
|
+
const m = new Map();
|
|
77
|
+
// For each param, bind it to its enum by type (only when that enum is in the table).
|
|
78
|
+
const bindEnums = (params) => {
|
|
79
|
+
const paramEnum = new Map();
|
|
80
|
+
for (const p of params) {
|
|
81
|
+
const bare = bareTypeName(p.type);
|
|
82
|
+
if (bare && enumTable.has(bare.toLowerCase()))
|
|
83
|
+
paramEnum.set(p.name, bare);
|
|
84
|
+
}
|
|
85
|
+
return paramEnum;
|
|
86
|
+
};
|
|
87
|
+
for (const c of components) {
|
|
88
|
+
m.set(c.name.toLowerCase(), {
|
|
89
|
+
params: new Set(c.parameters.map((p) => p.name)),
|
|
90
|
+
paramEnum: bindEnums(c.parameters),
|
|
91
|
+
});
|
|
92
|
+
for (const s of Object.values(c.subComponents)) {
|
|
93
|
+
m.set(s.componentName.toLowerCase(), {
|
|
94
|
+
params: new Set(s.parameters.map((p) => p.name)),
|
|
95
|
+
paramEnum: bindEnums(s.parameters),
|
|
96
|
+
parent: c.name,
|
|
97
|
+
parentCascades: s.parameters.some((p) => p.isCascading === true),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return m;
|
|
102
|
+
}
|
|
103
|
+
/** Builds a validateMarkup function bound to the given component catalog. `sharedEnums`
|
|
104
|
+
* supplies enums that live outside any component (e.g. Lumeo.Size) so type-bound enum
|
|
105
|
+
* validation works for shared types too. */
|
|
106
|
+
export function createValidator(components, sharedEnums = []) {
|
|
107
|
+
const enumTable = buildEnumTable(components, sharedEnums);
|
|
108
|
+
const elementIndex = buildElementIndex(components, enumTable);
|
|
109
|
+
return function validateMarkup(markup) {
|
|
110
|
+
const issues = [];
|
|
111
|
+
// Find component tags: <Foo ...> or <Foo .../> or </Foo>. Lumeo components are PascalCase.
|
|
112
|
+
// The attribute blob is quote-aware: a quoted value (Foo="...", Foo='...') can contain
|
|
113
|
+
// `<`/`>` and must not end the match early. Common valid Razor like
|
|
114
|
+
// Field="@(u => u.Name)" (a DataGrid column lambda) has a `>` inside the quoted
|
|
115
|
+
// attribute value; a naive `[^<>]*` stopped the match right there, truncating the tag
|
|
116
|
+
// and dropping every attribute after it from validation — and, worse, silently leaving
|
|
117
|
+
// a self-closing element on `openStack` (the truncated match never reaches its real
|
|
118
|
+
// `/>`), producing false nesting diagnostics for markup after it. Each alternative below
|
|
119
|
+
// consumes either a whole double-quoted or single-quoted run, or one plain non-quote/
|
|
120
|
+
// non-bracket character, so `<`/`>` inside a quoted value are absorbed instead of ending
|
|
121
|
+
// the tag.
|
|
122
|
+
const tagRx = /<\/?([A-Z][A-Za-z0-9]*)((?:\s+(?:"[^"]*"|'[^']*'|[^<>"'])*)?)\/?>/g;
|
|
123
|
+
// Stack of currently-open Lumeo elements for nesting-aware parent-child enforcement.
|
|
124
|
+
// Only known Lumeo components are tracked; non-Lumeo wrappers are invisible to this stack.
|
|
125
|
+
const openStack = [];
|
|
126
|
+
let m;
|
|
127
|
+
while ((m = tagRx.exec(markup)) !== null) {
|
|
128
|
+
const tag = m[1];
|
|
129
|
+
const isClose = m[0].startsWith("</");
|
|
130
|
+
// A tag ending in "/>" is self-closing: it opens and immediately closes, so it is
|
|
131
|
+
// never pushed onto openStack and is not a valid ancestor for anything.
|
|
132
|
+
const isSelfClosing = !isClose && m[0].endsWith("/>");
|
|
133
|
+
const known = elementIndex.get(tag.toLowerCase());
|
|
134
|
+
if (!known)
|
|
135
|
+
continue; // not a Lumeo element (could be a user component / HTML — ignore)
|
|
136
|
+
// Closing tag: pop the stack down to and including this element.
|
|
137
|
+
// Tolerate minor mis-nesting gracefully — stop at the first match found from the top;
|
|
138
|
+
// if not found (stray close tag), leave the stack unchanged rather than throwing.
|
|
139
|
+
if (isClose) {
|
|
140
|
+
const tagLower = tag.toLowerCase();
|
|
141
|
+
for (let i = openStack.length - 1; i >= 0; i--) {
|
|
142
|
+
const popped = openStack.pop();
|
|
143
|
+
if (popped.toLowerCase() === tagLower)
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Parent-child (nesting-aware): a sub-component that reads a CascadingValue from its
|
|
149
|
+
// parent (parentCascades=true) must have that parent as an OPEN ANCESTOR — i.e. the
|
|
150
|
+
// parent must currently be on the open-element stack, not merely present somewhere in
|
|
151
|
+
// the markup. Self-closing tags are checked against the stack at the moment they appear
|
|
152
|
+
// (they are never on the stack themselves). Purely presentational sub-components
|
|
153
|
+
// (parentCascades=false / undefined) are still allowed standalone.
|
|
154
|
+
if (known.parent && known.parentCascades) {
|
|
155
|
+
const parentLower = known.parent.toLowerCase();
|
|
156
|
+
if (!openStack.some((t) => t.toLowerCase() === parentLower)) {
|
|
157
|
+
issues.push({ severity: "error", component: tag, message: `<${tag}> must be used inside <${known.parent}> (it reads a CascadingValue from it). No <${known.parent}> found as an open ancestor.` });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Push opening (non-self-closing) tags onto the ancestor stack so they can act as
|
|
161
|
+
// valid parents for subsequent child elements.
|
|
162
|
+
if (!isSelfClosing)
|
|
163
|
+
openStack.push(tag);
|
|
164
|
+
// Parse attributes (best-effort): Name="..." | Name='...' | Name="@expr" | Name=@expr | bare-name
|
|
165
|
+
const attrBlob = m[2] ?? "";
|
|
166
|
+
const attrRx = /([@A-Za-z_][\w-]*)\s*=\s*("(?:[^"]*)"|'(?:[^']*)'|@?[^\s"'<>]+)|([@A-Za-z_][\w-]*)(?=\s|$)/g;
|
|
167
|
+
let am;
|
|
168
|
+
while ((am = attrRx.exec(attrBlob)) !== null) {
|
|
169
|
+
let name = (am[1] ?? am[3] ?? "").trim();
|
|
170
|
+
if (!name)
|
|
171
|
+
continue;
|
|
172
|
+
// Strip Blazor directive prefixes/suffixes: @bind-Foo, @bind-Foo:event, Foo:stopPropagation, @onclick, @attributes, @key, @ref, @bind
|
|
173
|
+
if (name.startsWith("@bind-"))
|
|
174
|
+
name = name.slice("@bind-".length).split(":")[0];
|
|
175
|
+
else if (name.startsWith("@bind"))
|
|
176
|
+
continue; // @bind / @bind:event on inputs — skip
|
|
177
|
+
else if (name.startsWith("@on") || name === "@attributes" || name === "@key" || name === "@ref" || name === "@oninput" || name === "@onchange")
|
|
178
|
+
continue;
|
|
179
|
+
else if (name.startsWith("@"))
|
|
180
|
+
continue; // other directives
|
|
181
|
+
if (name.includes(":"))
|
|
182
|
+
name = name.split(":")[0]; // EventName:stopPropagation, :preventDefault
|
|
183
|
+
if (name === "class" || name === "style" || name === "id")
|
|
184
|
+
continue; // pass-through HTML attrs (Lumeo captures unmatched)
|
|
185
|
+
if (/^data-|^aria-/i.test(name))
|
|
186
|
+
continue; // captured unmatched
|
|
187
|
+
if (GENERIC_TYPE_PARAMS_BY_COMPONENT[tag.toLowerCase()]?.has(name))
|
|
188
|
+
continue; // @typeparam binding (Foo TItem="Employee"), not a [Parameter]
|
|
189
|
+
if (!known.params.has(name)) {
|
|
190
|
+
// Could be a captured-unmatched HTML attr — only flag if it looks like a typo'd Lumeo param (PascalCase).
|
|
191
|
+
if (/^[A-Z]/.test(name)) {
|
|
192
|
+
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.)` });
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// Enum value check: Foo="Bar.Baz.Qux" or Foo="Qux". Bind the param to its
|
|
197
|
+
// declared enum TYPE (not a substring guess), then check the value against that
|
|
198
|
+
// enum's allowed values. This catches Size="Large" (Large ∉ Size) and never
|
|
199
|
+
// mis-attributes a value to an unrelated enum that merely shares a substring.
|
|
200
|
+
const rawVal = (am[2] ?? "").replace(/^["']|["']$/g, "").trim();
|
|
201
|
+
if (!rawVal || rawVal.startsWith("@"))
|
|
202
|
+
continue; // dynamic expression — can't statically check
|
|
203
|
+
const boundEnum = known.paramEnum.get(name);
|
|
204
|
+
if (boundEnum) {
|
|
205
|
+
const display = enumTable.get(boundEnum.toLowerCase());
|
|
206
|
+
const lastSegRaw = rawVal.split(".").pop();
|
|
207
|
+
const lastSeg = lastSegRaw.toLowerCase();
|
|
208
|
+
if (!display.some((v) => v.toLowerCase() === lastSeg)) {
|
|
209
|
+
issues.push({ severity: "error", component: tag, message: `\`${name}="${rawVal}"\` — \`${lastSegRaw}\` is not a valid ${boundEnum} value. Allowed: ${display.join(", ")}.` });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { ok: !issues.some((i) => i.severity === "error"), issues };
|
|
215
|
+
};
|
|
216
|
+
}
|