mlola-ui 1.0.3 → 1.0.4
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 +29 -2
- package/package.json +1 -1
- package/registry/agents.json +419 -0
- package/registry/agents.md +453 -0
- package/registry/assets.json +734 -110
- package/registry/catalog.json +1187 -0
- package/registry/contract.json +1064 -0
- package/registry/index.json +37 -37
- package/src/agents.js +119 -0
- package/src/cli.js +27 -2
- package/src/knowledge.js +235 -0
- package/src/mcp.js +241 -0
package/src/knowledge.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DEFAULT_CONFIG, interpolateTarget } from "./config.js";
|
|
4
|
+
import { BUNDLED_REGISTRY_ROOT, loadRegistry, readJson, sourcePath } from "./registry.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the CLI knows about Mlola UI, as answers rather than files: every
|
|
8
|
+
* component and catalog item, the tokens, the rules for new UI, and the
|
|
9
|
+
* element contract markup is checked against. All of it is read from the
|
|
10
|
+
* registry bundled with this CLI, so an answer always matches the version
|
|
11
|
+
* that `add` installs, and it works offline.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
function bundled(name) {
|
|
16
|
+
if (!cache.has(name)) {
|
|
17
|
+
const filename = path.join(BUNDLED_REGISTRY_ROOT, name);
|
|
18
|
+
cache.set(name, fs.existsSync(filename) ? (name.endsWith(".json") ? readJson(filename) : fs.readFileSync(filename, "utf8")) : null);
|
|
19
|
+
}
|
|
20
|
+
return cache.get(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const KIND = { "registry:ui": "component", "registry:block": "block", "registry:page": "page", "registry:template": "template" };
|
|
24
|
+
|
|
25
|
+
/** Every item, free and Pro, in one shape. */
|
|
26
|
+
export function allItems() {
|
|
27
|
+
const registry = loadRegistry();
|
|
28
|
+
const free = registry.items.map((item) => ({
|
|
29
|
+
name: item.name,
|
|
30
|
+
kind: KIND[item.type] ?? "component",
|
|
31
|
+
tier: "free",
|
|
32
|
+
title: item.title,
|
|
33
|
+
description: item.description,
|
|
34
|
+
category: item.category,
|
|
35
|
+
tags: item.tags ?? [],
|
|
36
|
+
variants: item.variants ?? [],
|
|
37
|
+
sizes: item.sizes ?? [],
|
|
38
|
+
}));
|
|
39
|
+
const pro = (bundled("catalog.json")?.items ?? []).map((item) => ({
|
|
40
|
+
name: item.name,
|
|
41
|
+
kind: KIND[item.type] ?? "component",
|
|
42
|
+
tier: "pro",
|
|
43
|
+
title: item.title,
|
|
44
|
+
description: item.description,
|
|
45
|
+
category: item.category,
|
|
46
|
+
tags: item.tags ?? [],
|
|
47
|
+
variants: [],
|
|
48
|
+
sizes: [],
|
|
49
|
+
}));
|
|
50
|
+
return [...free, ...pro];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Items matching a query: every word must appear in the name, title, description, category or tags. */
|
|
54
|
+
export function searchItems({ query = "", kind, category, tier } = {}) {
|
|
55
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
56
|
+
return allItems()
|
|
57
|
+
.filter((item) => (!kind || item.kind === kind) && (!category || item.category === category) && (!tier || item.tier === tier))
|
|
58
|
+
.map((item) => {
|
|
59
|
+
const haystack = [item.name, item.title, item.description, item.category, ...item.tags].join(" ").toLowerCase();
|
|
60
|
+
const score = words.reduce((sum, word) => sum + (item.name === word ? 5 : item.name.includes(word) ? 3 : haystack.includes(word) ? 1 : -100), 0);
|
|
61
|
+
return { item, score };
|
|
62
|
+
})
|
|
63
|
+
.filter(({ score }) => score >= 0)
|
|
64
|
+
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name))
|
|
65
|
+
.map(({ item }) => item);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The classes a component styles (its own prefix) and the attributes each reacts to. */
|
|
69
|
+
function elementsOf(name) {
|
|
70
|
+
const contract = bundled("contract.json");
|
|
71
|
+
if (!contract) return [];
|
|
72
|
+
const prefix = `ml-${name}`;
|
|
73
|
+
return contract.classes
|
|
74
|
+
.filter((cls) => cls === prefix || cls.startsWith(`${prefix}-`))
|
|
75
|
+
.map((cls) => ({ class: cls, attributes: contract.elements[cls] ?? {} }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function projectConfig(cwd) {
|
|
79
|
+
try {
|
|
80
|
+
return readJson(path.join(cwd, "mlola.config.json"));
|
|
81
|
+
} catch {
|
|
82
|
+
return DEFAULT_CONFIG;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Everything an agent needs to use one item correctly. */
|
|
87
|
+
export function describeItem(name, { cwd = process.cwd(), includeSource = false } = {}) {
|
|
88
|
+
const registry = loadRegistry();
|
|
89
|
+
const item = registry.items.find((entry) => entry.name === name);
|
|
90
|
+
if (!item) {
|
|
91
|
+
const pro = (bundled("catalog.json")?.items ?? []).find((entry) => entry.name === name);
|
|
92
|
+
if (pro) {
|
|
93
|
+
return {
|
|
94
|
+
name,
|
|
95
|
+
tier: "pro",
|
|
96
|
+
kind: KIND[pro.type] ?? "component",
|
|
97
|
+
title: pro.title,
|
|
98
|
+
description: pro.description,
|
|
99
|
+
category: pro.category,
|
|
100
|
+
install: `npx mlola-ui login <token> # once, with a token from https://ui.mlola.com/account\nnpx mlola-ui add ${name}`,
|
|
101
|
+
note: "Part of Mlola Pro. Its source arrives with a license token; its classes and attributes are listed in mlola-pro.agents.md after the first Pro install.",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const close = allItems().filter((entry) => entry.name.includes(name) || name.includes(entry.name)).map((entry) => entry.name).slice(0, 5);
|
|
105
|
+
throw new Error(`No item named "${name}".${close.length ? ` Did you mean: ${close.join(", ")}?` : ""} Search with search_components.`);
|
|
106
|
+
}
|
|
107
|
+
const config = projectConfig(cwd);
|
|
108
|
+
const usage = item.usage
|
|
109
|
+
? { import: `import { ${item.usage.exportName} } from "${interpolateTarget(item.usage.importPath, config, "import")}";`, exportName: item.usage.exportName }
|
|
110
|
+
: null;
|
|
111
|
+
const result = {
|
|
112
|
+
name,
|
|
113
|
+
tier: "free",
|
|
114
|
+
kind: KIND[item.type] ?? "component",
|
|
115
|
+
title: item.title,
|
|
116
|
+
description: item.description,
|
|
117
|
+
category: item.category,
|
|
118
|
+
variants: item.variants ?? [],
|
|
119
|
+
sizes: item.sizes ?? [],
|
|
120
|
+
usage,
|
|
121
|
+
install: `npx mlola-ui add ${name}`,
|
|
122
|
+
dependsOn: item.registryDependencies ?? [],
|
|
123
|
+
engineDependencies: item.engineDependencies ?? {},
|
|
124
|
+
elements: elementsOf(name),
|
|
125
|
+
};
|
|
126
|
+
if (includeSource) {
|
|
127
|
+
result.source = (item.files ?? [])
|
|
128
|
+
.filter((file) => !file.path.includes("/_internal/"))
|
|
129
|
+
.map((file) => ({ path: interpolateTarget(file.target, config), content: fs.readFileSync(sourcePath(file.path), "utf8") }));
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function designData() {
|
|
135
|
+
return bundled("agents.json");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function designGuide() {
|
|
139
|
+
return bundled("agents.md");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function tokens(group) {
|
|
143
|
+
const data = designData();
|
|
144
|
+
if (!data) return [];
|
|
145
|
+
const groups = data.tokens;
|
|
146
|
+
if (!group) return groups;
|
|
147
|
+
const wanted = group.toLowerCase();
|
|
148
|
+
return groups.filter((entry) => entry.group.toLowerCase().includes(wanted) || entry.names.some((name) => name.includes(wanted)));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function themes() {
|
|
152
|
+
const registry = loadRegistry();
|
|
153
|
+
return registry.themes ?? designData()?.themes ?? [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ── Checking markup against the contract ─────────────────────────── */
|
|
157
|
+
|
|
158
|
+
const SHARED = new Set(["data-tone", "data-variant", "data-size", "data-state", "data-status"]);
|
|
159
|
+
const UTILITY = /^(-?(m|p)[trblxy]?-\d|flex$|grid$|block$|inline|hidden$|items-|justify-|gap-|w-|h-|min-|max-|text-(xs|sm|base|lg|xl|\d|[a-z]+-\d)|font-(bold|medium|semibold|light)|bg-|border-|rounded|shadow|ring-|space-[xy]-|leading-|tracking-|opacity-|z-\d|absolute$|relative$|fixed$|sticky$|overflow-|col-span|row-span|sm:|md:|lg:|xl:|hover:|focus:|dark:)/;
|
|
160
|
+
const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Checks HTML or JSX against the element contract. It reads string-valued
|
|
164
|
+
* class, className, style and data-* attributes; expressions in braces are
|
|
165
|
+
* skipped, since their value is only known at runtime.
|
|
166
|
+
*/
|
|
167
|
+
export function checkMarkup(markup) {
|
|
168
|
+
const contract = bundled("contract.json");
|
|
169
|
+
const known = new Set(contract?.classes ?? []);
|
|
170
|
+
const proPrefixes = (bundled("catalog.json")?.items ?? []).map((item) => `ml-${item.name}`);
|
|
171
|
+
const themeIds = new Set(themes().map((theme) => theme.id));
|
|
172
|
+
const issues = [];
|
|
173
|
+
const note = (severity, tag, message, fix) => issues.push({ severity, element: tag.slice(0, 120), message, ...(fix ? { fix } : {}) });
|
|
174
|
+
|
|
175
|
+
for (const match of markup.matchAll(/<([A-Za-z][\w.-]*)((?:\s+(?:[^\s"'>={}]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}))?))*)\s*\/?>/g)) {
|
|
176
|
+
const tag = match[0];
|
|
177
|
+
const attributes = new Map();
|
|
178
|
+
for (const attribute of match[2].matchAll(/([^\s"'>={}]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\{(?:[^{}]|\{[^{}]*\})*\})))?/g)) {
|
|
179
|
+
const [, name, double, single, expression] = attribute;
|
|
180
|
+
const value = double ?? single ?? (expression ? /^\{\s*["'`]([^"'`$]*)["'`]\s*\}$/.exec(expression)?.[1] ?? null : "");
|
|
181
|
+
attributes.set(name, value);
|
|
182
|
+
if (expression) attributes.set(`${name}:raw`, expression);
|
|
183
|
+
}
|
|
184
|
+
const classValue = attributes.get("class") ?? attributes.get("className");
|
|
185
|
+
const classes = typeof classValue === "string" ? classValue.split(/\s+/).filter(Boolean) : [];
|
|
186
|
+
const mlola = classes.filter((cls) => cls.startsWith("ml-"));
|
|
187
|
+
|
|
188
|
+
for (const cls of mlola) {
|
|
189
|
+
if (known.has(cls) || proPrefixes.some((prefix) => cls === prefix || cls.startsWith(`${prefix}-`))) continue;
|
|
190
|
+
const base = [...known].find((candidate) => cls.startsWith(`${candidate}-`) && elementsOf(candidate.slice(3)).length);
|
|
191
|
+
note(
|
|
192
|
+
"error",
|
|
193
|
+
tag,
|
|
194
|
+
`"${cls}" is not a Mlola class.`,
|
|
195
|
+
base ? `Mlola has no variant classes: use "${base}" with a data-* attribute (data-variant, data-tone, data-size).` : "Search with search_components, or name your own element with your own prefix (not ml-).",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const utilities = classes.filter((cls) => !cls.startsWith("ml-") && UTILITY.test(cls));
|
|
199
|
+
if (utilities.length) {
|
|
200
|
+
note("warning", tag, `Utility classes (${utilities.slice(0, 4).join(" ")}) do nothing here: Mlola ships no utility framework.`, "Use a component or composition primitive; for your own CSS, read --ml-* tokens.");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const [name, value] of attributes) {
|
|
204
|
+
if (!name.startsWith("data-") || typeof value !== "string") continue;
|
|
205
|
+
if (name === "data-theme" && value && !themeIds.has(value) && !/^th-[0-9a-z]{12}$/.test(value)) {
|
|
206
|
+
note("error", tag, `data-theme="${value}" is not a theme.`, `Use one of: ${[...themeIds].join(", ")}, or a Studio theme id.`);
|
|
207
|
+
}
|
|
208
|
+
if (name === "data-mode" && !["light", "dark"].includes(value)) {
|
|
209
|
+
note("error", tag, `data-mode is "light" or "dark"; "${value}" belongs in an attribute of your own.`, "data-theme and data-mode belong to the engine. Use data-kind or data-state for a component's own meaning.");
|
|
210
|
+
}
|
|
211
|
+
if (!SHARED.has(name) || !mlola.length) continue;
|
|
212
|
+
const allowed = new Set();
|
|
213
|
+
let reacts = false;
|
|
214
|
+
for (const cls of mlola) {
|
|
215
|
+
const values = contract?.elements?.[cls]?.[name];
|
|
216
|
+
if (values) {
|
|
217
|
+
reacts = true;
|
|
218
|
+
for (const entry of values) allowed.add(entry);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (reacts && allowed.size && !allowed.has(value)) {
|
|
222
|
+
note("error", tag, `${name}="${value}" is not a value ${mlola.join(" ")} reacts to.`, `Allowed: ${[...allowed].sort().join(", ")}.`);
|
|
223
|
+
} else if (!reacts && mlola.every((cls) => known.has(cls))) {
|
|
224
|
+
note("warning", tag, `${name} has no effect on ${mlola.join(" ")}.`, `See get_component for the attributes it reacts to.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A style string, or a JSX style object, with a color written into it.
|
|
229
|
+
const style = attributes.get("style") ?? attributes.get("style:raw");
|
|
230
|
+
if (typeof style === "string" && COLOR.test(style)) {
|
|
231
|
+
note("error", tag, "A color is written by hand in style.", "Read a token: var(--ml-text), var(--ml-primary-text), var(--ml-surface)… (get_tokens).");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return issues;
|
|
235
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { checkMarkup, describeItem, designData, designGuide, searchItems, themes, tokens } from "./knowledge.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `mlola-ui mcp` — a Model Context Protocol server over stdio.
|
|
8
|
+
*
|
|
9
|
+
* A coding agent (Claude Code, Cursor, Codex, VS Code, …) starts it in the
|
|
10
|
+
* project and asks it about Mlola instead of recalling Tailwind from its
|
|
11
|
+
* training: which component fits, what it is called, which token to read,
|
|
12
|
+
* whether the markup it just wrote is right. It can also install components
|
|
13
|
+
* and set a project up. Answers come from the registry bundled with this
|
|
14
|
+
* CLI, so they match what `add` installs, offline.
|
|
15
|
+
*
|
|
16
|
+
* The protocol is newline-delimited JSON-RPC 2.0, small enough to speak
|
|
17
|
+
* without a dependency. Nothing but protocol messages is written to stdout;
|
|
18
|
+
* diagnostics go to stderr.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
22
|
+
|
|
23
|
+
const INSTRUCTIONS = `This project's UI is Mlola UI: native CSS classes (ml-*), data-* attributes for state and variant, and --ml-* tokens. There is no Tailwind.
|
|
24
|
+
Before writing UI: call get_design_rules once, then search_components for what you need and get_component for how to use it. Read tokens with get_tokens instead of writing colors, sizes, shadows or durations. After writing markup, run check_markup on it and fix what it reports.`;
|
|
25
|
+
|
|
26
|
+
const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
27
|
+
|
|
28
|
+
function tools({ cwd, run }) {
|
|
29
|
+
const capture = async (argv) => {
|
|
30
|
+
const lines = [];
|
|
31
|
+
const output = { log: (...parts) => lines.push(parts.join(" ")), error: (...parts) => lines.push(parts.join(" ")) };
|
|
32
|
+
const code = await run(argv, { cwd, output });
|
|
33
|
+
return { code, output: lines.join("\n") };
|
|
34
|
+
};
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
name: "get_design_rules",
|
|
38
|
+
title: "Mlola design rules",
|
|
39
|
+
description: "The rules for building UI with Mlola: compose first, color by role, measure with the scales, shared state words, touch and focus. Read once before writing UI.",
|
|
40
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
41
|
+
annotations: { readOnlyHint: true },
|
|
42
|
+
handler: () => {
|
|
43
|
+
const data = designData();
|
|
44
|
+
return text(`${data?.rules ?? ""}\nThemes: ${themes().map((theme) => theme.id).join(", ")} (data-theme on any ancestor, data-mode light|dark).\nComposition primitives: ${(data?.primitives ?? []).map((entry) => entry.name).join(" ")}`);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "search_components",
|
|
49
|
+
title: "Search Mlola components",
|
|
50
|
+
description: "Find components, blocks, pages and templates by what they do (for example 'date range', 'chat input', 'pricing'). Returns name, tier (free or Mlola Pro) and a one-line description.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
query: { type: "string", description: "Words describing what you need. Empty lists everything." },
|
|
55
|
+
kind: { type: "string", enum: ["component", "block", "page", "template"] },
|
|
56
|
+
tier: { type: "string", enum: ["free", "pro"] },
|
|
57
|
+
},
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
},
|
|
60
|
+
annotations: { readOnlyHint: true },
|
|
61
|
+
handler: ({ query = "", kind, tier }) => {
|
|
62
|
+
const found = searchItems({ query, kind, tier });
|
|
63
|
+
return text(found.length ? found.slice(0, 40).map((item) => `${item.name} (${item.kind}, ${item.tier}) — ${item.description}`).join("\n") : `Nothing matches "${query}". Try other words, or search with an empty query to list everything.`);
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "get_component",
|
|
68
|
+
title: "How to use a Mlola component",
|
|
69
|
+
description: "Everything needed to use one item: import, variants, sizes, the classes it styles and the data-* values each reacts to, dependencies and the install command. Set include_source to read a free component's source.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: { name: { type: "string" }, include_source: { type: "boolean", default: false } },
|
|
73
|
+
required: ["name"],
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
annotations: { readOnlyHint: true },
|
|
77
|
+
handler: ({ name, include_source = false }) => text(describeItem(name, { cwd, includeSource: include_source })),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "get_tokens",
|
|
81
|
+
title: "Mlola design tokens",
|
|
82
|
+
description: "The --ml-* tokens grouped by purpose (planes and ink, color roles, spacing, type, density, shape, depth, motion, layers…). Filter with a group name or part of a token name.",
|
|
83
|
+
inputSchema: { type: "object", properties: { group: { type: "string", description: "For example 'spacing', 'color', 'shadow', 'radius'." } }, additionalProperties: false },
|
|
84
|
+
annotations: { readOnlyHint: true },
|
|
85
|
+
handler: ({ group }) => {
|
|
86
|
+
const found = tokens(group);
|
|
87
|
+
return text(found.length ? found.map((entry) => `${entry.group}: ${entry.purpose}\n ${entry.names.join(" ")}`).join("\n\n") : `No token group matches "${group}".`);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "check_markup",
|
|
92
|
+
title: "Check markup against the Mlola contract",
|
|
93
|
+
description: "Checks HTML or JSX: classes that do not exist, variant classes, data-* values a class does not react to, utility classes, hand-written colors, and misuse of data-theme or data-mode. Run it on markup you wrote before finishing.",
|
|
94
|
+
inputSchema: { type: "object", properties: { markup: { type: "string" } }, required: ["markup"], additionalProperties: false },
|
|
95
|
+
annotations: { readOnlyHint: true },
|
|
96
|
+
handler: ({ markup }) => {
|
|
97
|
+
const issues = checkMarkup(markup);
|
|
98
|
+
return text(issues.length ? issues : "No issues: every Mlola class exists and every data-* value is one its element reacts to.");
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "add_components",
|
|
103
|
+
title: "Install Mlola components",
|
|
104
|
+
description: "Copies components into the project with the Mlola CLI (npx mlola-ui add), with their dependencies and styles. Pro items need a login first.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: { names: { type: "array", items: { type: "string" }, minItems: 1 }, overwrite: { type: "boolean", default: false } },
|
|
108
|
+
required: ["names"],
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
},
|
|
111
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
112
|
+
handler: async ({ names, overwrite = false }) => {
|
|
113
|
+
const result = await capture(["add", ...names, ...(overwrite ? ["--overwrite"] : [])]);
|
|
114
|
+
return { ...text(result.output || "Done."), isError: result.code !== 0 };
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "init_project",
|
|
119
|
+
title: "Set a project up for Mlola",
|
|
120
|
+
description: "Runs npx mlola-ui init: writes mlola.config.json, the stylesheet entry and the agent instructions (AGENTS.md, the design guide, MCP config).",
|
|
121
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
122
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
123
|
+
handler: async () => {
|
|
124
|
+
const result = await capture(["init"]);
|
|
125
|
+
return { ...text(result.output), isError: result.code !== 0 };
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resources(cwd) {
|
|
132
|
+
const list = [{ uri: "mlola://guide", name: "Mlola UI design guide", description: "Classes, attributes, tokens and rules, generated from the stylesheet.", mimeType: "text/markdown" }];
|
|
133
|
+
if (fs.existsSync(path.join(cwd, "mlola-pro.agents.md"))) {
|
|
134
|
+
list.push({ uri: "mlola://guide/pro", name: "Mlola Pro design guide", description: "The classes and attributes of the Pro items installed here.", mimeType: "text/markdown" });
|
|
135
|
+
}
|
|
136
|
+
return list;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readResource(uri, cwd) {
|
|
140
|
+
if (uri === "mlola://guide") return designGuide() ?? "";
|
|
141
|
+
if (uri === "mlola://guide/pro") return fs.readFileSync(path.join(cwd, "mlola-pro.agents.md"), "utf8");
|
|
142
|
+
throw Object.assign(new Error(`Unknown resource ${uri}`), { code: -32002 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const PROMPTS = [
|
|
146
|
+
{
|
|
147
|
+
name: "build_ui",
|
|
148
|
+
title: "Build UI with Mlola",
|
|
149
|
+
description: "Build a screen or component the Mlola way, and check it.",
|
|
150
|
+
arguments: [{ name: "goal", description: "What to build.", required: true }],
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
function prompt(name, args) {
|
|
155
|
+
if (name !== "build_ui") throw Object.assign(new Error(`Unknown prompt ${name}`), { code: -32602 });
|
|
156
|
+
return {
|
|
157
|
+
description: "Build UI with Mlola",
|
|
158
|
+
messages: [
|
|
159
|
+
{
|
|
160
|
+
role: "user",
|
|
161
|
+
content: {
|
|
162
|
+
type: "text",
|
|
163
|
+
text: `Build this with Mlola UI: ${args?.goal ?? ""}\n\n1. Read the rules (get_design_rules).\n2. Find the components that cover it (search_components, get_component) and install what is missing (add_components).\n3. Compose with them and the composition primitives; write CSS only for what they do not cover, reading --ml-* tokens (get_tokens).\n4. Check the markup (check_markup) and fix every issue before you finish.`,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function serveMcp({ cwd, run, version, input = process.stdin, output = process.stdout }) {
|
|
171
|
+
const available = tools({ cwd, run });
|
|
172
|
+
const send = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
173
|
+
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
174
|
+
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
175
|
+
|
|
176
|
+
async function handle(message) {
|
|
177
|
+
const { id, method, params } = message ?? {};
|
|
178
|
+
const isRequest = id !== undefined && id !== null;
|
|
179
|
+
try {
|
|
180
|
+
switch (method) {
|
|
181
|
+
case "initialize": {
|
|
182
|
+
const requested = params?.protocolVersion;
|
|
183
|
+
reply(id, {
|
|
184
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
|
|
185
|
+
capabilities: { tools: { listChanged: false }, resources: { listChanged: false }, prompts: { listChanged: false } },
|
|
186
|
+
serverInfo: { name: "mlola-ui", title: "Mlola UI", version },
|
|
187
|
+
instructions: INSTRUCTIONS,
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
case "ping":
|
|
192
|
+
return reply(id, {});
|
|
193
|
+
case "tools/list":
|
|
194
|
+
return reply(id, { tools: available.map(({ handler, ...tool }) => tool) });
|
|
195
|
+
case "tools/call": {
|
|
196
|
+
const tool = available.find((entry) => entry.name === params?.name);
|
|
197
|
+
if (!tool) return fail(id, -32602, `Unknown tool ${params?.name}`);
|
|
198
|
+
try {
|
|
199
|
+
return reply(id, await tool.handler(params?.arguments ?? {}));
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// A tool that fails answers with the reason, so the agent can correct course.
|
|
202
|
+
return reply(id, { ...text(error.message), isError: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
case "resources/list":
|
|
206
|
+
return reply(id, { resources: resources(cwd) });
|
|
207
|
+
case "resources/templates/list":
|
|
208
|
+
return reply(id, { resourceTemplates: [] });
|
|
209
|
+
case "resources/read":
|
|
210
|
+
return reply(id, { contents: [{ uri: params?.uri, mimeType: "text/markdown", text: readResource(params?.uri, cwd) }] });
|
|
211
|
+
case "prompts/list":
|
|
212
|
+
return reply(id, { prompts: PROMPTS });
|
|
213
|
+
case "prompts/get":
|
|
214
|
+
return reply(id, prompt(params?.name, params?.arguments));
|
|
215
|
+
default:
|
|
216
|
+
if (method?.startsWith("notifications/")) return;
|
|
217
|
+
if (isRequest) fail(id, -32601, `Method not found: ${method}`);
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isRequest) fail(id, error.code ?? -32603, error.message);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
225
|
+
const pending = new Set();
|
|
226
|
+
for await (const line of lines) {
|
|
227
|
+
if (!line.trim()) continue;
|
|
228
|
+
let message;
|
|
229
|
+
try {
|
|
230
|
+
message = JSON.parse(line);
|
|
231
|
+
} catch {
|
|
232
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
for (const entry of Array.isArray(message) ? message : [message]) {
|
|
236
|
+
const task = handle(entry).finally(() => pending.delete(task));
|
|
237
|
+
pending.add(task);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
await Promise.all(pending);
|
|
241
|
+
}
|