@cascivo/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/index.d.mts +100 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +721 -0
- package/dist/index.mjs.map +1 -0
- package/dist/registry.json +18215 -0
- package/package.json +58 -0
- package/readme.body.md +41 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { argv } from "node:process";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
//#region src/registry.ts
|
|
11
|
+
const HERE$2 = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the registry.json location. Checks, in order: an explicit path, the
|
|
14
|
+
* `CASCIVO_REGISTRY_PATH` env var, a copy bundled next to the built server
|
|
15
|
+
* (published package), and the monorepo root (dev).
|
|
16
|
+
*/
|
|
17
|
+
function resolveRegistryPath(explicit) {
|
|
18
|
+
const candidates = [
|
|
19
|
+
explicit,
|
|
20
|
+
process.env.CASCIVO_REGISTRY_PATH,
|
|
21
|
+
join(HERE$2, "registry.json"),
|
|
22
|
+
join(HERE$2, "..", "..", "..", "registry.json"),
|
|
23
|
+
join(HERE$2, "..", "..", "registry.json")
|
|
24
|
+
].filter((p) => typeof p === "string" && p.length > 0);
|
|
25
|
+
return candidates.find((p) => existsSync(p)) ?? candidates[candidates.length - 1] ?? "registry.json";
|
|
26
|
+
}
|
|
27
|
+
function loadRegistry(path) {
|
|
28
|
+
const resolved = resolveRegistryPath(path);
|
|
29
|
+
const raw = JSON.parse(readFileSync(resolved, "utf8"));
|
|
30
|
+
if (!Array.isArray(raw.components)) throw new Error(`Invalid registry at ${resolved}: "components" must be an array`);
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
/** List component manifests, optionally filtered by category and/or type. */
|
|
34
|
+
function listComponents(registry, category, type) {
|
|
35
|
+
let entries = registry.components;
|
|
36
|
+
if (category) entries = entries.filter((c) => c.category === category);
|
|
37
|
+
if (type) entries = entries.filter((c) => c.type === type);
|
|
38
|
+
return entries.map((c) => c.meta);
|
|
39
|
+
}
|
|
40
|
+
/** Find one component manifest by name (matches registry name or meta name). */
|
|
41
|
+
function getComponent(registry, name) {
|
|
42
|
+
const target = name.toLowerCase();
|
|
43
|
+
return registry.components.find((c) => c.name.toLowerCase() === target || c.meta.name.toLowerCase() === target)?.meta;
|
|
44
|
+
}
|
|
45
|
+
const DIRECTORY_URL = "https://cascivo.com/r/registries.json";
|
|
46
|
+
const FETCH_TIMEOUT_MS = 15e3;
|
|
47
|
+
const MAX_BODY_BYTES = 1048576;
|
|
48
|
+
async function fetchWithGuards(url, fetchFn = fetch) {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetchFn(url, { signal: controller.signal });
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
const buf = await res.arrayBuffer();
|
|
55
|
+
if (buf.byteLength > MAX_BODY_BYTES) return null;
|
|
56
|
+
return new TextDecoder().decode(buf);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Fetch the central cascade registry directory. Returns empty array on failure. */
|
|
64
|
+
async function fetchDirectory(fetchFn) {
|
|
65
|
+
const raw = await fetchWithGuards(DIRECTORY_URL, fetchFn);
|
|
66
|
+
if (!raw) return [];
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (!Array.isArray(parsed)) return [];
|
|
70
|
+
return parsed;
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Fetch the component index from a namespace's registryUrl. Returns null on failure. */
|
|
76
|
+
async function fetchRegistryIndex(registryUrl, fetchFn) {
|
|
77
|
+
const raw = await fetchWithGuards(registryUrl.includes("{name}") ? registryUrl.replace("{name}", "registry").replace(/\/registry$/, "/registry.json") : registryUrl.replace(/\/?$/, "/registry.json").replace(/\/\/registry\.json$/, "/registry.json"), fetchFn);
|
|
78
|
+
if (!raw) return null;
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(raw);
|
|
81
|
+
if (!Array.isArray(parsed.components)) return null;
|
|
82
|
+
return parsed;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Parse CASCADE_REGISTRIES env var — returns empty array on failure. */
|
|
88
|
+
function getEnvRegistries() {
|
|
89
|
+
const raw = process.env["CASCADE_REGISTRIES"];
|
|
90
|
+
if (!raw) return [];
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (!Array.isArray(parsed)) return [];
|
|
94
|
+
return parsed;
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Merge directory entries with env entries (env wins on namespace collision). */
|
|
100
|
+
function mergeRegistries(directory, env) {
|
|
101
|
+
const map = /* @__PURE__ */ new Map();
|
|
102
|
+
for (const entry of directory) map.set(entry.namespace, entry);
|
|
103
|
+
for (const entry of env) map.set(entry.namespace, entry);
|
|
104
|
+
return Array.from(map.values());
|
|
105
|
+
}
|
|
106
|
+
/** Fuzzy search over name, tags, and description. */
|
|
107
|
+
function searchComponents(registry, query) {
|
|
108
|
+
const q = query.toLowerCase().trim();
|
|
109
|
+
if (q === "") return [];
|
|
110
|
+
return registry.components.filter((c) => c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) || c.tags.some((t) => t.toLowerCase().includes(q))).map((c) => c.meta);
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/theme.ts
|
|
114
|
+
/** Darken a color by mixing in black (modern CSS, no preprocessor). */
|
|
115
|
+
function darken(color, amount) {
|
|
116
|
+
return `color-mix(in oklab, ${color}, black ${amount}%)`;
|
|
117
|
+
}
|
|
118
|
+
/** Lighten a color by mixing in white. */
|
|
119
|
+
function lighten(color, amount) {
|
|
120
|
+
return `color-mix(in oklab, ${color}, white ${amount}%)`;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Generate a custom cascade theme as CSS. Maps the three input colors onto the
|
|
124
|
+
* semantic token layer; component tokens inherit automatically.
|
|
125
|
+
*/
|
|
126
|
+
function generateThemeCss(colors, name = "custom") {
|
|
127
|
+
const { primary, neutral, accent } = colors;
|
|
128
|
+
return `/* Cascade — Generated theme: ${name} */
|
|
129
|
+
|
|
130
|
+
@layer cascade.theme {
|
|
131
|
+
[data-theme='${name}'] {
|
|
132
|
+
color-scheme: light;
|
|
133
|
+
|
|
134
|
+
/* ── Surface (derived from neutral) ───────────────── */
|
|
135
|
+
--cascivo-color-bg: ${lighten(neutral, 96)};
|
|
136
|
+
--cascivo-color-bg-subtle: ${lighten(neutral, 92)};
|
|
137
|
+
--cascivo-color-surface: ${lighten(neutral, 98)};
|
|
138
|
+
--cascivo-color-surface-raised: ${lighten(neutral, 94)};
|
|
139
|
+
--cascivo-color-surface-overlay: ${lighten(neutral, 98)};
|
|
140
|
+
--cascivo-color-border: ${lighten(neutral, 80)};
|
|
141
|
+
--cascivo-color-border-strong: ${lighten(neutral, 65)};
|
|
142
|
+
|
|
143
|
+
/* ── Text (derived from neutral) ──────────────────── */
|
|
144
|
+
--cascivo-color-text: ${darken(neutral, 80)};
|
|
145
|
+
--cascivo-color-text-subtle: ${darken(neutral, 50)};
|
|
146
|
+
--cascivo-color-text-muted: ${lighten(neutral, 30)};
|
|
147
|
+
--cascivo-color-text-on-accent: #ffffff;
|
|
148
|
+
--cascivo-color-text-on-destructive: #ffffff;
|
|
149
|
+
|
|
150
|
+
/* ── Accent / primary interactive ─────────────────── */
|
|
151
|
+
--cascivo-color-accent: ${primary};
|
|
152
|
+
--cascivo-color-accent-hover: ${darken(primary, 12)};
|
|
153
|
+
--cascivo-color-accent-active: ${darken(primary, 24)};
|
|
154
|
+
--cascivo-color-accent-subtle: ${lighten(primary, 88)};
|
|
155
|
+
--cascivo-color-accent-muted: ${lighten(primary, 76)};
|
|
156
|
+
|
|
157
|
+
/* ── Secondary accent (info / focus) ──────────────── */
|
|
158
|
+
--cascivo-color-info: ${accent};
|
|
159
|
+
--cascivo-color-info-subtle: ${lighten(accent, 88)};
|
|
160
|
+
--cascivo-color-focus-ring: ${accent};
|
|
161
|
+
--cascivo-focus-ring: 0 0 0 3px ${lighten(accent, 20)};
|
|
162
|
+
|
|
163
|
+
/* ── Status ───────────────────────────────────────── */
|
|
164
|
+
--cascivo-color-destructive: #dc2626;
|
|
165
|
+
--cascivo-color-destructive-hover: ${darken("#dc2626", 12)};
|
|
166
|
+
--cascivo-color-destructive-subtle: ${lighten("#dc2626", 88)};
|
|
167
|
+
--cascivo-color-success: #16a34a;
|
|
168
|
+
--cascivo-color-success-subtle: ${lighten("#16a34a", 88)};
|
|
169
|
+
--cascivo-color-warning: #f59e0b;
|
|
170
|
+
--cascivo-color-warning-subtle: ${lighten("#f59e0b", 88)};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/scaffold.ts
|
|
177
|
+
const DEFAULT_COMPONENTS = [
|
|
178
|
+
"card",
|
|
179
|
+
"input",
|
|
180
|
+
"button"
|
|
181
|
+
];
|
|
182
|
+
function pascalCase(name) {
|
|
183
|
+
return name.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
184
|
+
}
|
|
185
|
+
/** A reasonable default usage snippet per component for the scaffold. */
|
|
186
|
+
function usage(component, Tag) {
|
|
187
|
+
switch (component) {
|
|
188
|
+
case "button": return ` <${Tag}>Get started</${Tag}>`;
|
|
189
|
+
case "input": return ` <${Tag} label="Email" placeholder="you@example.com" />`;
|
|
190
|
+
case "card": return ` <${Tag}>\n <h2>Card title</h2>\n <p>Card content goes here.</p>\n </${Tag}>`;
|
|
191
|
+
case "badge": return ` <${Tag}>New</${Tag}>`;
|
|
192
|
+
default: return ` <${Tag} />`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Generate a JSX page layout string from a natural-language description and an
|
|
197
|
+
* optional list of cascade components to include.
|
|
198
|
+
*/
|
|
199
|
+
function scaffoldPage(options) {
|
|
200
|
+
const components = options.components?.length ? options.components : DEFAULT_COMPONENTS;
|
|
201
|
+
const unique = [...new Set(components.map((c) => c.toLowerCase()))];
|
|
202
|
+
const imports = unique.map((c) => `import { ${pascalCase(c)} } from './components/ui/${c}/${c}'`).join("\n");
|
|
203
|
+
const body = unique.map((c) => usage(c, pascalCase(c))).join("\n");
|
|
204
|
+
return `${imports}
|
|
205
|
+
|
|
206
|
+
/* ${options.description} */
|
|
207
|
+
export function Page() {
|
|
208
|
+
return (
|
|
209
|
+
<main
|
|
210
|
+
data-theme="light"
|
|
211
|
+
style={{ maxWidth: '48rem', margin: '0 auto', padding: '2rem', display: 'grid', gap: '1rem' }}
|
|
212
|
+
>
|
|
213
|
+
${body}
|
|
214
|
+
</main>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
`;
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/validate.ts
|
|
221
|
+
const DATA_REF_RE = /^\$data\./;
|
|
222
|
+
const ACTION_REF_RE = /^\$actions\./;
|
|
223
|
+
function closestName(name, candidates) {
|
|
224
|
+
const target = name.toLowerCase();
|
|
225
|
+
let best;
|
|
226
|
+
let bestDist = 3;
|
|
227
|
+
for (const candidate of candidates) {
|
|
228
|
+
const dist = levenshtein(target, candidate.toLowerCase());
|
|
229
|
+
if (dist < bestDist) {
|
|
230
|
+
bestDist = dist;
|
|
231
|
+
best = candidate;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return best;
|
|
235
|
+
}
|
|
236
|
+
function levenshtein(a, b) {
|
|
237
|
+
const m = a.length;
|
|
238
|
+
const n = b.length;
|
|
239
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
|
|
240
|
+
for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
241
|
+
return dp[m][n];
|
|
242
|
+
}
|
|
243
|
+
function validateNode(node, path, errors, componentNames) {
|
|
244
|
+
if (typeof node !== "object" || node === null) {
|
|
245
|
+
errors.push({
|
|
246
|
+
path,
|
|
247
|
+
message: "Expected a component node object"
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const n = node;
|
|
252
|
+
if (typeof n["component"] !== "string") {
|
|
253
|
+
errors.push({
|
|
254
|
+
path: `${path}.component`,
|
|
255
|
+
message: "component must be a string"
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const componentName = n["component"];
|
|
260
|
+
if (!componentNames.has(componentName)) {
|
|
261
|
+
const suggestion = closestName(componentName, [...componentNames]);
|
|
262
|
+
const hint = suggestion ? ` Did you mean "${suggestion}"?` : "";
|
|
263
|
+
errors.push({
|
|
264
|
+
path: `${path}.component`,
|
|
265
|
+
message: `Unknown component "${componentName}".${hint}`
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const nodeBind = n["bind"] ?? {};
|
|
270
|
+
for (const [key, value] of Object.entries(nodeBind)) if (typeof value !== "string" || !DATA_REF_RE.test(value)) errors.push({
|
|
271
|
+
path: `${path}.bind.${key}`,
|
|
272
|
+
message: `bind values must start with "$data." — got "${value}"`
|
|
273
|
+
});
|
|
274
|
+
const nodeEvents = n["events"] ?? {};
|
|
275
|
+
for (const [key, value] of Object.entries(nodeEvents)) if (typeof value !== "string" || !ACTION_REF_RE.test(value)) errors.push({
|
|
276
|
+
path: `${path}.events.${key}`,
|
|
277
|
+
message: `events values must start with "$actions." — got "${value}"`
|
|
278
|
+
});
|
|
279
|
+
if (Array.isArray(n["children"])) for (let i = 0; i < n["children"].length; i++) validateNode(n["children"][i], `${path}.children[${i}]`, errors, componentNames);
|
|
280
|
+
}
|
|
281
|
+
/** Validate a ViewConfig object. Component names are checked against the provided set. */
|
|
282
|
+
function validateView(config, componentNames) {
|
|
283
|
+
const errors = [];
|
|
284
|
+
if (typeof config !== "object" || config === null) return {
|
|
285
|
+
valid: false,
|
|
286
|
+
errors: [{
|
|
287
|
+
path: "",
|
|
288
|
+
message: "Config must be an object"
|
|
289
|
+
}]
|
|
290
|
+
};
|
|
291
|
+
const view = config["view"];
|
|
292
|
+
if (typeof view !== "object" || view === null) return {
|
|
293
|
+
valid: false,
|
|
294
|
+
errors: [{
|
|
295
|
+
path: "view",
|
|
296
|
+
message: "view must be an object"
|
|
297
|
+
}]
|
|
298
|
+
};
|
|
299
|
+
const regions = view["regions"];
|
|
300
|
+
if (typeof regions !== "object" || regions === null) {
|
|
301
|
+
errors.push({
|
|
302
|
+
path: "view.regions",
|
|
303
|
+
message: "view.regions must be an object"
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
valid: false,
|
|
307
|
+
errors
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
for (const [regionName, nodes] of Object.entries(regions)) {
|
|
311
|
+
if (!Array.isArray(nodes)) {
|
|
312
|
+
errors.push({
|
|
313
|
+
path: `view.regions.${regionName}`,
|
|
314
|
+
message: `Region "${regionName}" must be an array of component nodes`
|
|
315
|
+
});
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
for (let i = 0; i < nodes.length; i++) validateNode(nodes[i], `view.regions.${regionName}[${i}]`, errors, componentNames);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
valid: errors.length === 0,
|
|
322
|
+
errors
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/scaffold-view.ts
|
|
327
|
+
/** Simple keyword matcher — returns region layout name based on description keywords. */
|
|
328
|
+
function pickLayout(description) {
|
|
329
|
+
const d = description.toLowerCase();
|
|
330
|
+
if (d.includes("dashboard") || d.includes("stats") || d.includes("kpi")) return "dashboard";
|
|
331
|
+
if (d.includes("settings") || d.includes("preferences") || d.includes("config")) return "settings";
|
|
332
|
+
if (d.includes("login") || d.includes("auth") || d.includes("sign")) return "auth";
|
|
333
|
+
return "none";
|
|
334
|
+
}
|
|
335
|
+
/** Pick sensible components from the registry matching keywords in description. */
|
|
336
|
+
function pickComponents(description, registry, explicit) {
|
|
337
|
+
if (explicit && explicit.length > 0) return explicit.map((c) => c.toLowerCase());
|
|
338
|
+
const d = description.toLowerCase();
|
|
339
|
+
const candidates = [];
|
|
340
|
+
for (const entry of registry.components) {
|
|
341
|
+
const name = entry.meta.name.toLowerCase();
|
|
342
|
+
const tags = entry.tags.join(" ").toLowerCase();
|
|
343
|
+
if (d.includes(name) || tags.split(" ").some((t) => d.includes(t))) candidates.push(entry.meta.name);
|
|
344
|
+
}
|
|
345
|
+
if (candidates.length === 0) candidates.push("Badge");
|
|
346
|
+
return candidates.slice(0, 4);
|
|
347
|
+
}
|
|
348
|
+
function makeNode(componentName, registry) {
|
|
349
|
+
const entry = registry.components.find((c) => c.meta.name.toLowerCase() === componentName.toLowerCase());
|
|
350
|
+
if (!entry) return { component: componentName };
|
|
351
|
+
const props = {};
|
|
352
|
+
for (const prop of entry.meta.props ?? []) if (prop.required && prop.default !== void 0) props[prop.name] = prop.default;
|
|
353
|
+
else if (prop.default !== void 0 && [
|
|
354
|
+
"string",
|
|
355
|
+
"number",
|
|
356
|
+
"boolean"
|
|
357
|
+
].includes(typeof prop.default)) {}
|
|
358
|
+
return Object.keys(props).length > 0 ? {
|
|
359
|
+
component: entry.meta.name,
|
|
360
|
+
props
|
|
361
|
+
} : { component: entry.meta.name };
|
|
362
|
+
}
|
|
363
|
+
function scaffoldView(input, registry) {
|
|
364
|
+
const layout = pickLayout(input.description);
|
|
365
|
+
const componentNames = pickComponents(input.description, registry, input.components);
|
|
366
|
+
const config = {
|
|
367
|
+
$schema: "https://raw.githubusercontent.com/urbanisierung/cascivo/main/packages/render/schema/view.v1.json",
|
|
368
|
+
version: 1,
|
|
369
|
+
view: {
|
|
370
|
+
...layout !== "none" ? { layout } : {},
|
|
371
|
+
regions: { main: componentNames.map((name) => makeNode(name, registry)) }
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const { errors } = validateView(config, new Set(registry.components.map((c) => c.meta.name)));
|
|
375
|
+
return {
|
|
376
|
+
config,
|
|
377
|
+
errors
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/tokens.ts
|
|
382
|
+
const HERE$1 = dirname(fileURLToPath(import.meta.url));
|
|
383
|
+
const CATALOG_BASE_URL = "https://cascivo.com";
|
|
384
|
+
async function loadTokenCatalog(fetchFn) {
|
|
385
|
+
const localPaths = [join(HERE$1, "..", "..", "..", "apps", "docs", "public", "tokens.catalog.json"), join(HERE$1, "tokens.catalog.json")];
|
|
386
|
+
for (const p of localPaths) if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8"));
|
|
387
|
+
const res = await (fetchFn ?? fetch)(`${CATALOG_BASE_URL}/tokens.catalog.json`);
|
|
388
|
+
if (!res.ok) throw new Error(`Failed to fetch token catalog: ${res.status}`);
|
|
389
|
+
return res.json();
|
|
390
|
+
}
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/context.ts
|
|
393
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
394
|
+
const CONTEXT_BASE_URL = "https://cascivo.com";
|
|
395
|
+
async function loadContext(fetchFn) {
|
|
396
|
+
const localPaths = [join(HERE, "..", "..", "..", "apps", "docs", "public", "context.json"), join(HERE, "context.json")];
|
|
397
|
+
for (const p of localPaths) if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8"));
|
|
398
|
+
const res = await (fetchFn ?? fetch)(`${CONTEXT_BASE_URL}/context.json`);
|
|
399
|
+
if (!res.ok) throw new Error(`Failed to fetch context bundle: ${res.status}`);
|
|
400
|
+
return res.json();
|
|
401
|
+
}
|
|
402
|
+
async function loadComponentMarkdown(name, fetchFn) {
|
|
403
|
+
const slug = name.toLowerCase().replace(/\s+/g, "-");
|
|
404
|
+
const localPaths = [join(HERE, "..", "..", "..", "apps", "docs", "public", "context", `${slug}.md`), join(HERE, "context", `${slug}.md`)];
|
|
405
|
+
for (const p of localPaths) if (existsSync(p)) return readFileSync(p, "utf8");
|
|
406
|
+
const url = `${CONTEXT_BASE_URL}/context/${slug}.md`;
|
|
407
|
+
const fn = fetchFn ?? fetch;
|
|
408
|
+
try {
|
|
409
|
+
const res = await fn(url);
|
|
410
|
+
if (!res.ok) return null;
|
|
411
|
+
return res.text();
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/select.ts
|
|
418
|
+
const MIN_WORD_LEN = 3;
|
|
419
|
+
function words(text) {
|
|
420
|
+
return (text.toLowerCase().match(/\b\w+\b/g) ?? []).filter((w) => w.length >= MIN_WORD_LEN);
|
|
421
|
+
}
|
|
422
|
+
function countMatches(needWords, text) {
|
|
423
|
+
const lower = text.toLowerCase();
|
|
424
|
+
return needWords.filter((w) => lower.includes(w));
|
|
425
|
+
}
|
|
426
|
+
function selectComponent(need, components) {
|
|
427
|
+
const needWords = words(need);
|
|
428
|
+
if (needWords.length === 0) return [];
|
|
429
|
+
const scored = components.map((c) => {
|
|
430
|
+
const whenToUseText = c.intent.whenToUse.join(" ");
|
|
431
|
+
const whenNotToUseText = c.intent.whenNotToUse.join(" ");
|
|
432
|
+
const whenToUseMatches = countMatches(needWords, whenToUseText);
|
|
433
|
+
const descMatches = countMatches(needWords, c.description);
|
|
434
|
+
const penaltyMatches = countMatches(needWords, whenNotToUseText);
|
|
435
|
+
const score = whenToUseMatches.length * 2 + descMatches.length - penaltyMatches.length * 3;
|
|
436
|
+
let why = "";
|
|
437
|
+
if (whenToUseMatches.length > 0) {
|
|
438
|
+
const bestSentence = c.intent.whenToUse.map((s) => ({
|
|
439
|
+
s,
|
|
440
|
+
count: countMatches(needWords, s).length
|
|
441
|
+
})).filter((x) => x.count > 0).sort((a, b) => b.count - a.count)[0]?.s;
|
|
442
|
+
why = bestSentence ? `Matched: ${whenToUseMatches.map((w) => `'${w}'`).join(", ")} in whenToUse — "${bestSentence}"` : `Matched: ${whenToUseMatches.map((w) => `'${w}'`).join(", ")} in description`;
|
|
443
|
+
} else if (descMatches.length > 0) why = `Matched: ${descMatches.map((w) => `'${w}'`).join(", ")} in description`;
|
|
444
|
+
return {
|
|
445
|
+
name: c.name,
|
|
446
|
+
score,
|
|
447
|
+
why,
|
|
448
|
+
related: c.intent.related ?? []
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
const top3Names = new Set([...scored].sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, 3).map((r) => r.name));
|
|
452
|
+
const relatedBonus = /* @__PURE__ */ new Map();
|
|
453
|
+
for (const r of scored) {
|
|
454
|
+
if (!top3Names.has(r.name)) continue;
|
|
455
|
+
for (const rel of r.related) relatedBonus.set(rel.name, (relatedBonus.get(rel.name) ?? 0) + 1);
|
|
456
|
+
}
|
|
457
|
+
return scored.map((r) => ({
|
|
458
|
+
name: r.name,
|
|
459
|
+
score: r.score + (relatedBonus.get(r.name) ?? 0),
|
|
460
|
+
why: r.why
|
|
461
|
+
})).filter((r) => r.score >= 0).sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, 5);
|
|
462
|
+
}
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/server.ts
|
|
465
|
+
const json = (value) => ({ content: [{
|
|
466
|
+
type: "text",
|
|
467
|
+
text: JSON.stringify(value, null, 2)
|
|
468
|
+
}] });
|
|
469
|
+
const text = (value) => ({ content: [{
|
|
470
|
+
type: "text",
|
|
471
|
+
text: value
|
|
472
|
+
}] });
|
|
473
|
+
const error = (message) => ({
|
|
474
|
+
content: [{
|
|
475
|
+
type: "text",
|
|
476
|
+
text: message
|
|
477
|
+
}],
|
|
478
|
+
isError: true
|
|
479
|
+
});
|
|
480
|
+
/** Build a configured McpServer exposing the cascade component registry. */
|
|
481
|
+
function createServer(options = {}) {
|
|
482
|
+
const registry = loadRegistry(options.registryPath);
|
|
483
|
+
const fetchFn = options.fetchFn;
|
|
484
|
+
const server = new McpServer({
|
|
485
|
+
name: "@cascivo/mcp",
|
|
486
|
+
version: options.version ?? "0.0.0"
|
|
487
|
+
});
|
|
488
|
+
server.registerTool("list_registries", {
|
|
489
|
+
title: "List registries",
|
|
490
|
+
description: "List available component registries: the cascade directory plus any configured via CASCADE_REGISTRIES env.",
|
|
491
|
+
inputSchema: {}
|
|
492
|
+
}, async () => {
|
|
493
|
+
const [directory] = await Promise.all([fetchDirectory(fetchFn)]);
|
|
494
|
+
return json(mergeRegistries(directory, getEnvRegistries()).map(({ namespace, name, description, verified, homepage }) => ({
|
|
495
|
+
namespace,
|
|
496
|
+
name,
|
|
497
|
+
description,
|
|
498
|
+
verified: verified ?? false,
|
|
499
|
+
homepage
|
|
500
|
+
})));
|
|
501
|
+
});
|
|
502
|
+
server.registerTool("list_components", {
|
|
503
|
+
title: "List components",
|
|
504
|
+
description: "List all cascade components, optionally filtered by category and/or type.",
|
|
505
|
+
inputSchema: {
|
|
506
|
+
category: z.enum([
|
|
507
|
+
"inputs",
|
|
508
|
+
"display",
|
|
509
|
+
"overlay",
|
|
510
|
+
"navigation",
|
|
511
|
+
"feedback",
|
|
512
|
+
"chart"
|
|
513
|
+
]).optional().describe("Filter by component category"),
|
|
514
|
+
type: z.enum([
|
|
515
|
+
"component",
|
|
516
|
+
"layout",
|
|
517
|
+
"block",
|
|
518
|
+
"chart"
|
|
519
|
+
]).optional().describe("Filter by entry type")
|
|
520
|
+
}
|
|
521
|
+
}, ({ category, type }) => json(listComponents(registry, category, type)));
|
|
522
|
+
server.registerTool("get_component", {
|
|
523
|
+
title: "Get component",
|
|
524
|
+
description: "Get the full manifest (props, states, tokens, a11y, examples) for one component.",
|
|
525
|
+
inputSchema: {
|
|
526
|
+
name: z.string().describe("Component name, e.g. \"button\""),
|
|
527
|
+
registry: z.string().optional().describe("Namespace of an external registry, e.g. \"@myns\". Omit for the default.")
|
|
528
|
+
}
|
|
529
|
+
}, async ({ name, registry: ns }) => {
|
|
530
|
+
if (ns) {
|
|
531
|
+
const env = getEnvRegistries();
|
|
532
|
+
const entry = mergeRegistries(await fetchDirectory(fetchFn), env).find((e) => e.namespace === ns);
|
|
533
|
+
if (!entry) return error(`Registry "${ns}" not found.`);
|
|
534
|
+
const remoteRegistry = await fetchRegistryIndex(entry.registryUrl, fetchFn);
|
|
535
|
+
if (!remoteRegistry) return error(`Failed to fetch registry index for "${ns}".`);
|
|
536
|
+
const meta = getComponent(remoteRegistry, name);
|
|
537
|
+
return meta ? json(meta) : error(`Component "${name}" not found in registry "${ns}".`);
|
|
538
|
+
}
|
|
539
|
+
const meta = getComponent(registry, name);
|
|
540
|
+
return meta ? json(meta) : error(`Component "${name}" not found.`);
|
|
541
|
+
});
|
|
542
|
+
server.registerTool("search_components", {
|
|
543
|
+
title: "Search components",
|
|
544
|
+
description: "Fuzzy search components by name, tags, or description.",
|
|
545
|
+
inputSchema: {
|
|
546
|
+
query: z.string().describe("Search query"),
|
|
547
|
+
registry: z.string().optional().describe("Namespace of an external registry, e.g. \"@myns\". Omit for the default.")
|
|
548
|
+
}
|
|
549
|
+
}, async ({ query, registry: ns }) => {
|
|
550
|
+
if (ns) {
|
|
551
|
+
const env = getEnvRegistries();
|
|
552
|
+
const entry = mergeRegistries(await fetchDirectory(fetchFn), env).find((e) => e.namespace === ns);
|
|
553
|
+
if (!entry) return error(`Registry "${ns}" not found.`);
|
|
554
|
+
const remoteRegistry = await fetchRegistryIndex(entry.registryUrl, fetchFn);
|
|
555
|
+
if (!remoteRegistry) return error(`Failed to fetch registry index for "${ns}".`);
|
|
556
|
+
return json(searchComponents(remoteRegistry, query));
|
|
557
|
+
}
|
|
558
|
+
return json(searchComponents(registry, query));
|
|
559
|
+
});
|
|
560
|
+
server.registerTool("add_to_project", {
|
|
561
|
+
title: "Add to project",
|
|
562
|
+
description: "Add a component to the current project by running the cascade CLI.",
|
|
563
|
+
inputSchema: {
|
|
564
|
+
name: z.string().describe("Component name to add"),
|
|
565
|
+
outputDir: z.string().optional().describe("Directory to write the component into")
|
|
566
|
+
}
|
|
567
|
+
}, ({ name, outputDir }) => {
|
|
568
|
+
const env = outputDir ? {
|
|
569
|
+
...process.env,
|
|
570
|
+
CASCIVO_OUTPUT_DIR: outputDir
|
|
571
|
+
} : process.env;
|
|
572
|
+
const result = spawnSync("npx", [
|
|
573
|
+
"-y",
|
|
574
|
+
"cascivo",
|
|
575
|
+
"add",
|
|
576
|
+
name
|
|
577
|
+
], {
|
|
578
|
+
encoding: "utf8",
|
|
579
|
+
env
|
|
580
|
+
});
|
|
581
|
+
if (result.status !== 0) return error(result.stderr || result.error?.message || `Failed to add "${name}".`);
|
|
582
|
+
return text(result.stdout || `Added ${name}.`);
|
|
583
|
+
});
|
|
584
|
+
server.registerTool("create_theme", {
|
|
585
|
+
title: "Create theme",
|
|
586
|
+
description: "Generate a cascade theme CSS file from three colors.",
|
|
587
|
+
inputSchema: {
|
|
588
|
+
primary: z.string().describe("Primary / interactive color (any CSS color)"),
|
|
589
|
+
neutral: z.string().describe("Base neutral color for surfaces and text"),
|
|
590
|
+
accent: z.string().describe("Secondary accent color (info / focus)"),
|
|
591
|
+
name: z.string().optional().describe("Theme name (default: \"custom\")")
|
|
592
|
+
}
|
|
593
|
+
}, ({ primary, neutral, accent, name }) => text(generateThemeCss({
|
|
594
|
+
primary,
|
|
595
|
+
neutral,
|
|
596
|
+
accent
|
|
597
|
+
}, name)));
|
|
598
|
+
server.registerTool("scaffold_page", {
|
|
599
|
+
title: "Scaffold page (deprecated)",
|
|
600
|
+
description: "[Deprecated — use scaffold_view instead] Generate a JSX page layout from a description.",
|
|
601
|
+
inputSchema: {
|
|
602
|
+
description: z.string().describe("What the page is for"),
|
|
603
|
+
components: z.array(z.string()).optional().describe("Components to include")
|
|
604
|
+
}
|
|
605
|
+
}, ({ description, components }) => {
|
|
606
|
+
const { config } = scaffoldView({
|
|
607
|
+
description,
|
|
608
|
+
...components ? { components } : {}
|
|
609
|
+
}, registry);
|
|
610
|
+
return text(`[Deprecated] Use scaffold_view for config-first output. JSX scaffold:\n\n${scaffoldPage(components ? {
|
|
611
|
+
description,
|
|
612
|
+
components
|
|
613
|
+
} : { description })}\n\n--- scaffold_view output ---\n${JSON.stringify(config, null, 2)}`);
|
|
614
|
+
});
|
|
615
|
+
server.registerTool("validate_view", {
|
|
616
|
+
title: "Validate view config",
|
|
617
|
+
description: "Validate a CascadeView JSON config against the registry. Returns errors with exact paths.",
|
|
618
|
+
inputSchema: { config: z.record(z.string(), z.unknown()).describe("The ViewConfig object to validate") }
|
|
619
|
+
}, ({ config }) => {
|
|
620
|
+
return json(validateView(config, new Set(registry.components.map((c) => c.meta.name))));
|
|
621
|
+
});
|
|
622
|
+
server.registerTool("scaffold_view", {
|
|
623
|
+
title: "Scaffold view config",
|
|
624
|
+
description: "Generate a valid starter ViewConfig from a description. Always validates before returning.",
|
|
625
|
+
inputSchema: {
|
|
626
|
+
description: z.string().describe("What the page/view is for"),
|
|
627
|
+
components: z.array(z.string()).optional().describe("Component names to include")
|
|
628
|
+
}
|
|
629
|
+
}, ({ description, components }) => {
|
|
630
|
+
const { config, errors } = scaffoldView({
|
|
631
|
+
description,
|
|
632
|
+
...components ? { components } : {}
|
|
633
|
+
}, registry);
|
|
634
|
+
if (errors.length > 0) return json({
|
|
635
|
+
valid: false,
|
|
636
|
+
errors,
|
|
637
|
+
config
|
|
638
|
+
});
|
|
639
|
+
return json({
|
|
640
|
+
valid: true,
|
|
641
|
+
config
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
server.registerTool("get_tokens", {
|
|
645
|
+
title: "Get tokens",
|
|
646
|
+
description: "Get the cascade token catalog (closed set). Agents must select from this catalog rather than hard-coding values.",
|
|
647
|
+
inputSchema: {
|
|
648
|
+
group: z.string().optional().describe("Filter by token group, e.g. \"color\", \"space\", \"radius\""),
|
|
649
|
+
layer: z.enum([
|
|
650
|
+
"primitive",
|
|
651
|
+
"semantic",
|
|
652
|
+
"component"
|
|
653
|
+
]).optional().describe("Filter by layer")
|
|
654
|
+
}
|
|
655
|
+
}, async ({ group, layer }) => {
|
|
656
|
+
try {
|
|
657
|
+
let tokens = (await loadTokenCatalog(fetchFn)).tokens;
|
|
658
|
+
if (group) tokens = tokens.filter((t) => t.group === group);
|
|
659
|
+
if (layer) tokens = tokens.filter((t) => t.layer === layer);
|
|
660
|
+
return json({
|
|
661
|
+
count: tokens.length,
|
|
662
|
+
tokens
|
|
663
|
+
});
|
|
664
|
+
} catch (e) {
|
|
665
|
+
return error(`Token catalog unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
server.registerTool("get_context", {
|
|
669
|
+
title: "Get context",
|
|
670
|
+
description: "Get intent, whenToUse/whenNotToUse, and authoring guidance for one component by name.",
|
|
671
|
+
inputSchema: { name: z.string().describe("Component name, e.g. \"Toast\"") }
|
|
672
|
+
}, async ({ name }) => {
|
|
673
|
+
try {
|
|
674
|
+
const ctx = await loadContext(fetchFn);
|
|
675
|
+
const target = name.toLowerCase();
|
|
676
|
+
const component = ctx.components.find((c) => c.name.toLowerCase() === target);
|
|
677
|
+
if (!component) return error(`Component "${name}" not found. Available: ${ctx.components.map((c) => c.name).join(", ")}`);
|
|
678
|
+
const md = await loadComponentMarkdown(component.name, fetchFn);
|
|
679
|
+
return json({
|
|
680
|
+
name: component.name,
|
|
681
|
+
category: component.category,
|
|
682
|
+
description: component.description,
|
|
683
|
+
intent: component.intent,
|
|
684
|
+
contextUrl: component.contextUrl,
|
|
685
|
+
...md ? { markdown: md } : {}
|
|
686
|
+
});
|
|
687
|
+
} catch (e) {
|
|
688
|
+
return error(`Context bundle unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
server.registerTool("select_component", {
|
|
692
|
+
title: "Select component",
|
|
693
|
+
description: "Heuristic ranking of cascade components by natural language need. Returns top matches with scores and reasons. Note: heuristic ranking, not a model call.",
|
|
694
|
+
inputSchema: { need: z.string().describe("Natural language description of what the component should do") }
|
|
695
|
+
}, async ({ need }) => {
|
|
696
|
+
try {
|
|
697
|
+
return json({
|
|
698
|
+
note: "Heuristic ranking — not a model call. Verify with get_context before using.",
|
|
699
|
+
results: selectComponent(need, (await loadContext(fetchFn)).components)
|
|
700
|
+
});
|
|
701
|
+
} catch (e) {
|
|
702
|
+
return error(`Context bundle unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
return server;
|
|
706
|
+
}
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region src/index.ts
|
|
709
|
+
const VERSION = "0.0.0";
|
|
710
|
+
/** Start the MCP server over stdio. */
|
|
711
|
+
async function main() {
|
|
712
|
+
await createServer({ version: VERSION }).connect(new StdioServerTransport());
|
|
713
|
+
}
|
|
714
|
+
if (argv[1] !== void 0 && import.meta.url === pathToFileURL(argv[1]).href) main().catch((err) => {
|
|
715
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
716
|
+
process.exitCode = 1;
|
|
717
|
+
});
|
|
718
|
+
//#endregion
|
|
719
|
+
export { VERSION, createServer, generateThemeCss, getComponent, listComponents, loadRegistry, main, scaffoldPage, searchComponents };
|
|
720
|
+
|
|
721
|
+
//# sourceMappingURL=index.mjs.map
|