@fragments-sdk/mcp 0.3.0 → 0.4.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/dist/bin.js +1722 -0
- package/dist/bin.js.map +1 -0
- package/package.json +3 -2
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1722 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { readFile } from "fs/promises";
|
|
11
|
+
import { existsSync as existsSync2 } from "fs";
|
|
12
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
13
|
+
import { join as join2 } from "path";
|
|
14
|
+
|
|
15
|
+
// src/constants.ts
|
|
16
|
+
var BRAND = {
|
|
17
|
+
/** Display name (e.g., "Fragments") */
|
|
18
|
+
name: "Fragments",
|
|
19
|
+
/** Lowercase name for file paths and CLI (e.g., "fragments") */
|
|
20
|
+
nameLower: "fragments",
|
|
21
|
+
/** File extension for fragment definition files (e.g., ".fragment.tsx") */
|
|
22
|
+
fileExtension: ".fragment.tsx",
|
|
23
|
+
/** Legacy file extension for segments (still supported for migration) */
|
|
24
|
+
legacyFileExtension: ".segment.tsx",
|
|
25
|
+
/** JSON file extension for compiled output */
|
|
26
|
+
jsonExtension: ".fragment.json",
|
|
27
|
+
/** Default output file name (e.g., "fragments.json") */
|
|
28
|
+
outFile: "fragments.json",
|
|
29
|
+
/** Config file name (e.g., "fragments.config.ts") */
|
|
30
|
+
configFile: "fragments.config.ts",
|
|
31
|
+
/** Legacy config file name (still supported for migration) */
|
|
32
|
+
legacyConfigFile: "segments.config.ts",
|
|
33
|
+
/** CLI command name (e.g., "fragments") */
|
|
34
|
+
cliCommand: "fragments",
|
|
35
|
+
/** Package scope (e.g., "@fragments") */
|
|
36
|
+
packageScope: "@fragments",
|
|
37
|
+
/** Directory for storing fragments, registry, and cache */
|
|
38
|
+
dataDir: ".fragments",
|
|
39
|
+
/** Components subdirectory within .fragments/ */
|
|
40
|
+
componentsDir: "components",
|
|
41
|
+
/** Registry file name */
|
|
42
|
+
registryFile: "registry.json",
|
|
43
|
+
/** Context file name (AI-ready markdown) */
|
|
44
|
+
contextFile: "context.md",
|
|
45
|
+
/** Screenshots subdirectory */
|
|
46
|
+
screenshotsDir: "screenshots",
|
|
47
|
+
/** Cache subdirectory (gitignored) */
|
|
48
|
+
cacheDir: "cache",
|
|
49
|
+
/** Diff output subdirectory (gitignored) */
|
|
50
|
+
diffDir: "diff",
|
|
51
|
+
/** Manifest filename */
|
|
52
|
+
manifestFile: "manifest.json",
|
|
53
|
+
/** Prefix for localStorage keys (e.g., "fragments-") */
|
|
54
|
+
storagePrefix: "fragments-",
|
|
55
|
+
/** Static viewer HTML file name */
|
|
56
|
+
viewerHtmlFile: "fragments-viewer.html",
|
|
57
|
+
/** MCP tool name prefix (e.g., "fragments_") */
|
|
58
|
+
mcpToolPrefix: "fragments_",
|
|
59
|
+
/** File extension for block definition files */
|
|
60
|
+
blockFileExtension: ".block.ts",
|
|
61
|
+
/** @deprecated Use blockFileExtension instead */
|
|
62
|
+
recipeFileExtension: ".recipe.ts",
|
|
63
|
+
/** Vite plugin namespace */
|
|
64
|
+
vitePluginNamespace: "fragments-core-shim"
|
|
65
|
+
};
|
|
66
|
+
var DEFAULTS = {
|
|
67
|
+
/** Default viewport dimensions */
|
|
68
|
+
viewport: {
|
|
69
|
+
width: 1280,
|
|
70
|
+
height: 800
|
|
71
|
+
},
|
|
72
|
+
/** Default diff threshold (percentage) */
|
|
73
|
+
diffThreshold: 5,
|
|
74
|
+
/** Browser pool size */
|
|
75
|
+
poolSize: 3,
|
|
76
|
+
/** Idle timeout before browser shutdown (ms) - 5 minutes */
|
|
77
|
+
idleTimeoutMs: 5 * 60 * 1e3,
|
|
78
|
+
/** Delay after render before capture (ms) */
|
|
79
|
+
captureDelayMs: 100,
|
|
80
|
+
/** Font loading timeout (ms) */
|
|
81
|
+
fontTimeoutMs: 3e3,
|
|
82
|
+
/** Default theme */
|
|
83
|
+
theme: "light",
|
|
84
|
+
/** Dev server port */
|
|
85
|
+
port: 6006
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ../context/dist/chunk-KKABP4K4.js
|
|
89
|
+
var PLACEHOLDER_PATTERNS = [
|
|
90
|
+
/^\w+ component is needed$/i,
|
|
91
|
+
/^Alternative component is more appropriate$/i,
|
|
92
|
+
/^Use \w+ when you need/i
|
|
93
|
+
];
|
|
94
|
+
function filterPlaceholders(items) {
|
|
95
|
+
if (!items) return [];
|
|
96
|
+
return items.filter(
|
|
97
|
+
(item) => !PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(item.trim()))
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
function generateContext(segments, options = {}, blocks) {
|
|
101
|
+
const format = options.format ?? "markdown";
|
|
102
|
+
const compact = options.compact ?? false;
|
|
103
|
+
const include = {
|
|
104
|
+
props: options.include?.props ?? true,
|
|
105
|
+
variants: options.include?.variants ?? true,
|
|
106
|
+
usage: options.include?.usage ?? true,
|
|
107
|
+
relations: options.include?.relations ?? false,
|
|
108
|
+
code: options.include?.code ?? false
|
|
109
|
+
};
|
|
110
|
+
const sorted = [...segments].sort((a, b) => {
|
|
111
|
+
const catCompare = a.meta.category.localeCompare(b.meta.category);
|
|
112
|
+
if (catCompare !== 0) return catCompare;
|
|
113
|
+
return a.meta.name.localeCompare(b.meta.name);
|
|
114
|
+
});
|
|
115
|
+
if (format === "json") {
|
|
116
|
+
return generateJsonContext(sorted, include, compact, blocks);
|
|
117
|
+
}
|
|
118
|
+
return generateMarkdownContext(sorted, include, compact, blocks);
|
|
119
|
+
}
|
|
120
|
+
function generateMarkdownContext(segments, include, compact, blocks) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push("# Design System Reference");
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push("## Quick Reference");
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push("| Component | Category | Use For |");
|
|
127
|
+
lines.push("|-----------|----------|---------|");
|
|
128
|
+
for (const segment of segments) {
|
|
129
|
+
const filteredWhen = filterPlaceholders(segment.usage.when);
|
|
130
|
+
const useFor = filteredWhen.slice(0, 2).join(", ") || segment.meta.description;
|
|
131
|
+
lines.push(`| ${segment.meta.name} | ${segment.meta.category} | ${truncate(useFor, 50)} |`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("");
|
|
134
|
+
if (compact) {
|
|
135
|
+
const content2 = lines.join("\n");
|
|
136
|
+
return { content: content2, tokenEstimate: estimateTokens(content2) };
|
|
137
|
+
}
|
|
138
|
+
lines.push("## Components");
|
|
139
|
+
lines.push("");
|
|
140
|
+
for (const segment of segments) {
|
|
141
|
+
lines.push(`### ${segment.meta.name}`);
|
|
142
|
+
lines.push("");
|
|
143
|
+
const statusParts = [`**Category:** ${segment.meta.category}`];
|
|
144
|
+
if (segment.meta.status) {
|
|
145
|
+
statusParts.push(`**Status:** ${segment.meta.status}`);
|
|
146
|
+
}
|
|
147
|
+
lines.push(statusParts.join(" | "));
|
|
148
|
+
lines.push("");
|
|
149
|
+
if (segment.meta.description) {
|
|
150
|
+
lines.push(segment.meta.description);
|
|
151
|
+
lines.push("");
|
|
152
|
+
}
|
|
153
|
+
const whenFiltered = filterPlaceholders(segment.usage.when);
|
|
154
|
+
const whenNotFiltered = filterPlaceholders(segment.usage.whenNot);
|
|
155
|
+
if (include.usage && (whenFiltered.length > 0 || whenNotFiltered.length > 0)) {
|
|
156
|
+
if (whenFiltered.length > 0) {
|
|
157
|
+
lines.push("**When to use:**");
|
|
158
|
+
for (const when of whenFiltered) {
|
|
159
|
+
lines.push(`- ${when}`);
|
|
160
|
+
}
|
|
161
|
+
lines.push("");
|
|
162
|
+
}
|
|
163
|
+
if (whenNotFiltered.length > 0) {
|
|
164
|
+
lines.push("**When NOT to use:**");
|
|
165
|
+
for (const whenNot of whenNotFiltered) {
|
|
166
|
+
lines.push(`- ${whenNot}`);
|
|
167
|
+
}
|
|
168
|
+
lines.push("");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (include.props && Object.keys(segment.props).length > 0) {
|
|
172
|
+
lines.push("**Props:**");
|
|
173
|
+
for (const [name, prop] of Object.entries(segment.props)) {
|
|
174
|
+
lines.push(`- \`${name}\`: ${formatPropType(prop)}${prop.required ? " (required)" : ""}`);
|
|
175
|
+
}
|
|
176
|
+
lines.push("");
|
|
177
|
+
}
|
|
178
|
+
if (include.variants && segment.variants.length > 0) {
|
|
179
|
+
const variantNames = segment.variants.map((v) => v.name).join(", ");
|
|
180
|
+
lines.push(`**Variants:** ${variantNames}`);
|
|
181
|
+
lines.push("");
|
|
182
|
+
if (include.code) {
|
|
183
|
+
for (const variant of segment.variants) {
|
|
184
|
+
if (variant.code) {
|
|
185
|
+
lines.push(`*${variant.name}:*`);
|
|
186
|
+
lines.push("```tsx");
|
|
187
|
+
lines.push(variant.code);
|
|
188
|
+
lines.push("```");
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (include.relations && segment.relations && segment.relations.length > 0) {
|
|
195
|
+
lines.push("**Related:**");
|
|
196
|
+
for (const relation of segment.relations) {
|
|
197
|
+
lines.push(`- ${relation.component} (${relation.relationship}): ${relation.note}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push("");
|
|
200
|
+
}
|
|
201
|
+
lines.push("---");
|
|
202
|
+
lines.push("");
|
|
203
|
+
}
|
|
204
|
+
if (blocks && blocks.length > 0) {
|
|
205
|
+
lines.push("## Blocks");
|
|
206
|
+
lines.push("");
|
|
207
|
+
lines.push("Composition patterns showing how components wire together.");
|
|
208
|
+
lines.push("");
|
|
209
|
+
for (const block of blocks) {
|
|
210
|
+
lines.push(`### ${block.name}`);
|
|
211
|
+
lines.push("");
|
|
212
|
+
lines.push(block.description);
|
|
213
|
+
lines.push("");
|
|
214
|
+
lines.push(`**Category:** ${block.category}`);
|
|
215
|
+
lines.push(`**Components:** ${block.components.join(", ")}`);
|
|
216
|
+
if (block.tags && block.tags.length > 0) {
|
|
217
|
+
lines.push(`**Tags:** ${block.tags.join(", ")}`);
|
|
218
|
+
}
|
|
219
|
+
lines.push("");
|
|
220
|
+
lines.push("```tsx");
|
|
221
|
+
lines.push(block.code);
|
|
222
|
+
lines.push("```");
|
|
223
|
+
lines.push("");
|
|
224
|
+
lines.push("---");
|
|
225
|
+
lines.push("");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const content = lines.join("\n");
|
|
229
|
+
return { content, tokenEstimate: estimateTokens(content) };
|
|
230
|
+
}
|
|
231
|
+
function generateJsonContext(segments, include, compact, blocks) {
|
|
232
|
+
const categories = [...new Set(segments.map((s) => s.meta.category))].sort();
|
|
233
|
+
const components = {};
|
|
234
|
+
for (const segment of segments) {
|
|
235
|
+
const component = {
|
|
236
|
+
category: segment.meta.category,
|
|
237
|
+
description: segment.meta.description
|
|
238
|
+
};
|
|
239
|
+
if (segment.meta.status) {
|
|
240
|
+
component.status = segment.meta.status;
|
|
241
|
+
}
|
|
242
|
+
if (!compact) {
|
|
243
|
+
if (include.usage) {
|
|
244
|
+
const whenFiltered = filterPlaceholders(segment.usage.when);
|
|
245
|
+
const whenNotFiltered = filterPlaceholders(segment.usage.whenNot);
|
|
246
|
+
if (whenFiltered.length > 0) component.whenToUse = whenFiltered;
|
|
247
|
+
if (whenNotFiltered.length > 0) component.whenNotToUse = whenNotFiltered;
|
|
248
|
+
}
|
|
249
|
+
if (include.props && Object.keys(segment.props).length > 0) {
|
|
250
|
+
component.props = {};
|
|
251
|
+
for (const [name, prop] of Object.entries(segment.props)) {
|
|
252
|
+
component.props[name] = {
|
|
253
|
+
type: formatPropType(prop),
|
|
254
|
+
description: prop.description
|
|
255
|
+
};
|
|
256
|
+
if (prop.required) component.props[name].required = true;
|
|
257
|
+
if (prop.default !== void 0) component.props[name].default = prop.default;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (include.variants && segment.variants.length > 0) {
|
|
261
|
+
component.variants = segment.variants.map((v) => v.name);
|
|
262
|
+
}
|
|
263
|
+
if (include.relations && segment.relations && segment.relations.length > 0) {
|
|
264
|
+
component.relations = segment.relations.map((r) => ({
|
|
265
|
+
component: r.component,
|
|
266
|
+
relationship: r.relationship,
|
|
267
|
+
note: r.note
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
components[segment.meta.name] = component;
|
|
272
|
+
}
|
|
273
|
+
const blocksMap = blocks && blocks.length > 0 ? Object.fromEntries(blocks.map((b) => [b.name, {
|
|
274
|
+
description: b.description,
|
|
275
|
+
category: b.category,
|
|
276
|
+
components: b.components,
|
|
277
|
+
code: b.code,
|
|
278
|
+
tags: b.tags
|
|
279
|
+
}])) : void 0;
|
|
280
|
+
const output = {
|
|
281
|
+
version: "1.0",
|
|
282
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
283
|
+
summary: {
|
|
284
|
+
totalComponents: segments.length,
|
|
285
|
+
categories,
|
|
286
|
+
...blocksMap && { totalBlocks: blocks.length }
|
|
287
|
+
},
|
|
288
|
+
components,
|
|
289
|
+
...blocksMap && { blocks: blocksMap }
|
|
290
|
+
};
|
|
291
|
+
const content = JSON.stringify(output, null, 2);
|
|
292
|
+
return { content, tokenEstimate: estimateTokens(content) };
|
|
293
|
+
}
|
|
294
|
+
function formatPropType(prop) {
|
|
295
|
+
if (prop.type === "enum" && prop.values) {
|
|
296
|
+
return prop.values.map((v) => `"${v}"`).join(" | ");
|
|
297
|
+
}
|
|
298
|
+
if (prop.default !== void 0) {
|
|
299
|
+
return `${prop.type} (default: ${JSON.stringify(prop.default)})`;
|
|
300
|
+
}
|
|
301
|
+
return prop.type;
|
|
302
|
+
}
|
|
303
|
+
function truncate(str, maxLength) {
|
|
304
|
+
if (str.length <= maxLength) return str;
|
|
305
|
+
return str.slice(0, maxLength - 3) + "...";
|
|
306
|
+
}
|
|
307
|
+
function estimateTokens(text) {
|
|
308
|
+
return Math.ceil(text.length / 4);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/discovery.ts
|
|
312
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
313
|
+
import { join, dirname, resolve } from "path";
|
|
314
|
+
import { createRequire } from "module";
|
|
315
|
+
function resolveWorkspaceGlob(baseDir, pattern) {
|
|
316
|
+
const parts = pattern.split("/");
|
|
317
|
+
let dirs = [baseDir];
|
|
318
|
+
for (const part of parts) {
|
|
319
|
+
if (part === "**") continue;
|
|
320
|
+
const next = [];
|
|
321
|
+
for (const d of dirs) {
|
|
322
|
+
if (part === "*") {
|
|
323
|
+
try {
|
|
324
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
325
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
326
|
+
next.push(join(d, entry.name));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
const candidate = join(d, part);
|
|
333
|
+
if (existsSync(candidate)) next.push(candidate);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
dirs = next;
|
|
337
|
+
}
|
|
338
|
+
return dirs;
|
|
339
|
+
}
|
|
340
|
+
function getWorkspaceDirs(rootDir) {
|
|
341
|
+
const dirs = [];
|
|
342
|
+
const rootPkgPath = join(rootDir, "package.json");
|
|
343
|
+
if (existsSync(rootPkgPath)) {
|
|
344
|
+
try {
|
|
345
|
+
const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf-8"));
|
|
346
|
+
const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : rootPkg.workspaces?.packages;
|
|
347
|
+
if (Array.isArray(workspaces)) {
|
|
348
|
+
for (const pattern of workspaces) {
|
|
349
|
+
dirs.push(...resolveWorkspaceGlob(rootDir, pattern));
|
|
350
|
+
}
|
|
351
|
+
return dirs;
|
|
352
|
+
}
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const pnpmWsPath = join(rootDir, "pnpm-workspace.yaml");
|
|
357
|
+
if (existsSync(pnpmWsPath)) {
|
|
358
|
+
try {
|
|
359
|
+
const content = readFileSync(pnpmWsPath, "utf-8");
|
|
360
|
+
const lines = content.split("\n");
|
|
361
|
+
let inPackages = false;
|
|
362
|
+
for (const line of lines) {
|
|
363
|
+
if (/^packages\s*:/.test(line)) {
|
|
364
|
+
inPackages = true;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (inPackages) {
|
|
368
|
+
const match = line.match(/^\s+-\s+['"]?([^'"#\n]+)['"]?/);
|
|
369
|
+
if (match) {
|
|
370
|
+
dirs.push(...resolveWorkspaceGlob(rootDir, match[1].trim()));
|
|
371
|
+
} else if (/^\S/.test(line) && line.trim()) {
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return dirs;
|
|
380
|
+
}
|
|
381
|
+
function resolveDepPackageJson(localRequire, depName) {
|
|
382
|
+
try {
|
|
383
|
+
return localRequire.resolve(`${depName}/package.json`);
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const mainPath = localRequire.resolve(depName);
|
|
388
|
+
let dir = dirname(mainPath);
|
|
389
|
+
while (true) {
|
|
390
|
+
const candidate = join(dir, "package.json");
|
|
391
|
+
if (existsSync(candidate)) {
|
|
392
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
393
|
+
if (pkg.name === depName) return candidate;
|
|
394
|
+
}
|
|
395
|
+
const parent = dirname(dir);
|
|
396
|
+
if (parent === dir) break;
|
|
397
|
+
dir = parent;
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
function findFragmentsInDeps(dir, found) {
|
|
404
|
+
const pkgJsonPath = join(dir, "package.json");
|
|
405
|
+
if (!existsSync(pkgJsonPath)) return;
|
|
406
|
+
try {
|
|
407
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
408
|
+
const allDeps = {
|
|
409
|
+
...pkgJson.dependencies,
|
|
410
|
+
...pkgJson.devDependencies
|
|
411
|
+
};
|
|
412
|
+
const localRequire = createRequire(join(dir, "noop.js"));
|
|
413
|
+
for (const depName of Object.keys(allDeps)) {
|
|
414
|
+
try {
|
|
415
|
+
const depPkgPath = resolveDepPackageJson(localRequire, depName);
|
|
416
|
+
if (!depPkgPath) continue;
|
|
417
|
+
const depPkg = JSON.parse(readFileSync(depPkgPath, "utf-8"));
|
|
418
|
+
if (depPkg.fragments) {
|
|
419
|
+
const fragmentsPath = join(dirname(depPkgPath), depPkg.fragments);
|
|
420
|
+
if (existsSync(fragmentsPath) && !found.includes(fragmentsPath)) {
|
|
421
|
+
found.push(fragmentsPath);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function findFragmentsJson(startDir) {
|
|
431
|
+
const found = [];
|
|
432
|
+
const resolvedStart = resolve(startDir);
|
|
433
|
+
let dir = resolvedStart;
|
|
434
|
+
while (true) {
|
|
435
|
+
const candidate = join(dir, BRAND.outFile);
|
|
436
|
+
if (existsSync(candidate)) {
|
|
437
|
+
found.push(candidate);
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
const parent = dirname(dir);
|
|
441
|
+
if (parent === dir) break;
|
|
442
|
+
dir = parent;
|
|
443
|
+
}
|
|
444
|
+
findFragmentsInDeps(resolvedStart, found);
|
|
445
|
+
if (found.length === 0 || existsSync(join(resolvedStart, "pnpm-workspace.yaml"))) {
|
|
446
|
+
const workspaceDirs = getWorkspaceDirs(resolvedStart);
|
|
447
|
+
for (const wsDir of workspaceDirs) {
|
|
448
|
+
findFragmentsInDeps(wsDir, found);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return found;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/search.ts
|
|
455
|
+
var CONVEX_SEARCH_URL = "https://combative-jay-834.convex.site/search";
|
|
456
|
+
var CONVEX_TIMEOUT_MS = 3e3;
|
|
457
|
+
var SYNONYM_MAP = {
|
|
458
|
+
"form": ["input", "field", "submit", "validation"],
|
|
459
|
+
"input": ["form", "field", "text", "entry"],
|
|
460
|
+
"button": ["action", "click", "submit", "trigger"],
|
|
461
|
+
"action": ["button", "click", "trigger"],
|
|
462
|
+
"alert": ["notification", "message", "warning", "error", "feedback"],
|
|
463
|
+
"notification": ["alert", "message", "toast"],
|
|
464
|
+
"card": ["container", "panel", "box", "content"],
|
|
465
|
+
"toggle": ["switch", "checkbox", "boolean", "on/off"],
|
|
466
|
+
"switch": ["toggle", "checkbox", "boolean"],
|
|
467
|
+
"badge": ["tag", "label", "status", "indicator"],
|
|
468
|
+
"status": ["badge", "indicator", "state"],
|
|
469
|
+
"login": ["auth", "signin", "authentication", "form"],
|
|
470
|
+
"auth": ["login", "signin", "authentication"],
|
|
471
|
+
"chat": ["message", "conversation", "ai"],
|
|
472
|
+
"table": ["data", "grid", "list", "rows"]
|
|
473
|
+
};
|
|
474
|
+
async function searchConvex(query, limit = 10, kind) {
|
|
475
|
+
try {
|
|
476
|
+
const controller = new AbortController();
|
|
477
|
+
const timeout = setTimeout(() => controller.abort(), CONVEX_TIMEOUT_MS);
|
|
478
|
+
const response = await fetch(CONVEX_SEARCH_URL, {
|
|
479
|
+
method: "POST",
|
|
480
|
+
headers: { "Content-Type": "application/json" },
|
|
481
|
+
body: JSON.stringify({ query, limit, ...kind && { kind } }),
|
|
482
|
+
signal: controller.signal
|
|
483
|
+
});
|
|
484
|
+
clearTimeout(timeout);
|
|
485
|
+
if (!response.ok) {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
const data = await response.json();
|
|
489
|
+
return data.results.map((r, i) => ({
|
|
490
|
+
name: r.name,
|
|
491
|
+
kind: r.kind ?? "component",
|
|
492
|
+
rank: i,
|
|
493
|
+
score: r.score
|
|
494
|
+
}));
|
|
495
|
+
} catch {
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function keywordScoreComponents(query, segments) {
|
|
500
|
+
const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
501
|
+
const expandedTerms = new Set(searchTerms);
|
|
502
|
+
for (const term of searchTerms) {
|
|
503
|
+
const synonyms = SYNONYM_MAP[term];
|
|
504
|
+
if (synonyms) {
|
|
505
|
+
for (const syn of synonyms) expandedTerms.add(syn);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const scored = segments.map((s) => {
|
|
509
|
+
let score = 0;
|
|
510
|
+
const nameLower = s.meta.name.toLowerCase();
|
|
511
|
+
if (searchTerms.some((term) => nameLower.includes(term))) {
|
|
512
|
+
score += 15;
|
|
513
|
+
} else if (Array.from(expandedTerms).some((term) => nameLower.includes(term))) {
|
|
514
|
+
score += 8;
|
|
515
|
+
}
|
|
516
|
+
const desc = s.meta.description?.toLowerCase() ?? "";
|
|
517
|
+
score += searchTerms.filter((term) => desc.includes(term)).length * 6;
|
|
518
|
+
const tags = s.meta.tags?.map((t) => t.toLowerCase()) ?? [];
|
|
519
|
+
score += searchTerms.filter((term) => tags.some((tag) => tag.includes(term))).length * 4;
|
|
520
|
+
const whenUsed = s.usage?.when?.join(" ").toLowerCase() ?? "";
|
|
521
|
+
score += searchTerms.filter((term) => whenUsed.includes(term)).length * 10;
|
|
522
|
+
score += Array.from(expandedTerms).filter((term) => !searchTerms.includes(term) && whenUsed.includes(term)).length * 5;
|
|
523
|
+
const cat = s.meta.category?.toLowerCase() ?? "";
|
|
524
|
+
if (searchTerms.some((term) => cat.includes(term))) {
|
|
525
|
+
score += 8;
|
|
526
|
+
}
|
|
527
|
+
const variantText = s.variants.map((v) => `${v.name} ${v.description || ""}`.toLowerCase()).join(" ");
|
|
528
|
+
score += searchTerms.filter((term) => variantText.includes(term)).length * 3;
|
|
529
|
+
if (s.meta.status === "stable") score += 5;
|
|
530
|
+
else if (s.meta.status === "beta") score += 2;
|
|
531
|
+
if (s.meta.status === "deprecated") score -= 25;
|
|
532
|
+
return { name: s.meta.name, kind: "component", score };
|
|
533
|
+
});
|
|
534
|
+
return scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score).map((s, i) => ({ ...s, rank: i }));
|
|
535
|
+
}
|
|
536
|
+
function keywordScoreBlocks(query, blocks) {
|
|
537
|
+
const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
538
|
+
const expandedTerms = new Set(searchTerms);
|
|
539
|
+
for (const term of searchTerms) {
|
|
540
|
+
const synonyms = SYNONYM_MAP[term];
|
|
541
|
+
if (synonyms) {
|
|
542
|
+
for (const syn of synonyms) expandedTerms.add(syn);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const scored = blocks.map((b) => {
|
|
546
|
+
let score = 0;
|
|
547
|
+
const nameLower = b.name.toLowerCase();
|
|
548
|
+
if (searchTerms.some((term) => nameLower.includes(term))) {
|
|
549
|
+
score += 15;
|
|
550
|
+
} else if (Array.from(expandedTerms).some((term) => nameLower.includes(term))) {
|
|
551
|
+
score += 8;
|
|
552
|
+
}
|
|
553
|
+
const desc = b.description.toLowerCase();
|
|
554
|
+
score += searchTerms.filter((term) => desc.includes(term)).length * 6;
|
|
555
|
+
const tags = b.tags?.map((t) => t.toLowerCase()) ?? [];
|
|
556
|
+
score += searchTerms.filter((term) => tags.some((tag) => tag.includes(term))).length * 4;
|
|
557
|
+
const componentText = b.components.join(" ").toLowerCase();
|
|
558
|
+
score += searchTerms.filter((term) => componentText.includes(term)).length * 5;
|
|
559
|
+
const cat = b.category.toLowerCase();
|
|
560
|
+
if (searchTerms.some((term) => cat.includes(term))) {
|
|
561
|
+
score += 8;
|
|
562
|
+
}
|
|
563
|
+
return { name: b.name, kind: "block", score };
|
|
564
|
+
});
|
|
565
|
+
return scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score).map((s, i) => ({ ...s, rank: i }));
|
|
566
|
+
}
|
|
567
|
+
function keywordScoreTokens(query, tokenData) {
|
|
568
|
+
const searchTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
569
|
+
const scored = [];
|
|
570
|
+
for (const [cat, tokens] of Object.entries(tokenData.categories)) {
|
|
571
|
+
const catLower = cat.toLowerCase();
|
|
572
|
+
const catBonus = searchTerms.some((term) => catLower.includes(term)) ? 8 : 0;
|
|
573
|
+
for (const token of tokens) {
|
|
574
|
+
let score = catBonus;
|
|
575
|
+
const nameLower = token.name.toLowerCase();
|
|
576
|
+
score += searchTerms.filter((term) => nameLower.includes(term)).length * 10;
|
|
577
|
+
if (token.description) {
|
|
578
|
+
const descLower = token.description.toLowerCase();
|
|
579
|
+
score += searchTerms.filter((term) => descLower.includes(term)).length * 6;
|
|
580
|
+
}
|
|
581
|
+
if (score > 0) {
|
|
582
|
+
scored.push({ name: token.name, kind: "token", score });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return scored.sort((a, b) => b.score - a.score).map((s, i) => ({ ...s, rank: i }));
|
|
587
|
+
}
|
|
588
|
+
function reciprocalRankFusion(resultSets, k = 60) {
|
|
589
|
+
const scoreMap = /* @__PURE__ */ new Map();
|
|
590
|
+
for (const { results } of resultSets) {
|
|
591
|
+
for (let rank = 0; rank < results.length; rank++) {
|
|
592
|
+
const result = results[rank];
|
|
593
|
+
const key = `${result.kind}:${result.name}`;
|
|
594
|
+
const rrfScore = 1 / (k + rank + 1);
|
|
595
|
+
const existing = scoreMap.get(key);
|
|
596
|
+
if (existing) {
|
|
597
|
+
existing.score += rrfScore;
|
|
598
|
+
} else {
|
|
599
|
+
scoreMap.set(key, { score: rrfScore, kind: result.kind, name: result.name });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const fused = [];
|
|
604
|
+
for (const [, { score, kind, name }] of scoreMap) {
|
|
605
|
+
fused.push({ name, kind, rank: 0, score });
|
|
606
|
+
}
|
|
607
|
+
fused.sort((a, b) => b.score - a.score);
|
|
608
|
+
fused.forEach((r, i) => {
|
|
609
|
+
r.rank = i;
|
|
610
|
+
});
|
|
611
|
+
return fused;
|
|
612
|
+
}
|
|
613
|
+
async function hybridSearch(query, data, limit = 10, kind) {
|
|
614
|
+
const keywordResults = [];
|
|
615
|
+
if (!kind || kind === "component") {
|
|
616
|
+
keywordResults.push(...keywordScoreComponents(query, data.segments));
|
|
617
|
+
}
|
|
618
|
+
if ((!kind || kind === "block") && data.blocks) {
|
|
619
|
+
keywordResults.push(...keywordScoreBlocks(query, data.blocks));
|
|
620
|
+
}
|
|
621
|
+
if ((!kind || kind === "token") && data.tokenData) {
|
|
622
|
+
keywordResults.push(...keywordScoreTokens(query, data.tokenData));
|
|
623
|
+
}
|
|
624
|
+
keywordResults.sort((a, b) => b.score - a.score);
|
|
625
|
+
keywordResults.forEach((r, i) => {
|
|
626
|
+
r.rank = i;
|
|
627
|
+
});
|
|
628
|
+
const vectorResults = await searchConvex(query, limit, kind);
|
|
629
|
+
if (vectorResults.length === 0) {
|
|
630
|
+
return keywordResults.slice(0, limit);
|
|
631
|
+
}
|
|
632
|
+
const fused = reciprocalRankFusion([
|
|
633
|
+
{ label: "vector", results: vectorResults },
|
|
634
|
+
{ label: "keyword", results: keywordResults }
|
|
635
|
+
]);
|
|
636
|
+
return fused.slice(0, limit);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/service.ts
|
|
640
|
+
async function renderComponent(viewerUrl2, request) {
|
|
641
|
+
const renderUrl = `${viewerUrl2}/fragments/render`;
|
|
642
|
+
const response = await fetch(renderUrl, {
|
|
643
|
+
method: "POST",
|
|
644
|
+
headers: { "Content-Type": "application/json" },
|
|
645
|
+
body: JSON.stringify({
|
|
646
|
+
component: request.component,
|
|
647
|
+
props: request.props ?? {},
|
|
648
|
+
viewport: request.viewport ?? { width: 800, height: 600 }
|
|
649
|
+
})
|
|
650
|
+
});
|
|
651
|
+
return await response.json();
|
|
652
|
+
}
|
|
653
|
+
async function compareComponent(viewerUrl2, request) {
|
|
654
|
+
const compareUrl = `${viewerUrl2}/fragments/compare`;
|
|
655
|
+
const response = await fetch(compareUrl, {
|
|
656
|
+
method: "POST",
|
|
657
|
+
headers: { "Content-Type": "application/json" },
|
|
658
|
+
body: JSON.stringify(request)
|
|
659
|
+
});
|
|
660
|
+
return await response.json();
|
|
661
|
+
}
|
|
662
|
+
async function fixComponent(viewerUrl2, request) {
|
|
663
|
+
const fixUrl = `${viewerUrl2}/fragments/fix`;
|
|
664
|
+
const response = await fetch(fixUrl, {
|
|
665
|
+
method: "POST",
|
|
666
|
+
headers: { "Content-Type": "application/json" },
|
|
667
|
+
body: JSON.stringify(request)
|
|
668
|
+
});
|
|
669
|
+
return await response.json();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/utils.ts
|
|
673
|
+
function projectFields(obj, fields) {
|
|
674
|
+
if (!fields || fields.length === 0) {
|
|
675
|
+
return obj;
|
|
676
|
+
}
|
|
677
|
+
const result = {};
|
|
678
|
+
for (const field of fields) {
|
|
679
|
+
const parts = field.split(".");
|
|
680
|
+
let source = obj;
|
|
681
|
+
let target = result;
|
|
682
|
+
for (let i = 0; i < parts.length; i++) {
|
|
683
|
+
const part = parts[i];
|
|
684
|
+
const isLast = i === parts.length - 1;
|
|
685
|
+
if (source === null || source === void 0 || typeof source !== "object") {
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
const sourceObj = source;
|
|
689
|
+
const value = sourceObj[part];
|
|
690
|
+
if (isLast) {
|
|
691
|
+
target[part] = value;
|
|
692
|
+
} else {
|
|
693
|
+
if (!(part in target)) {
|
|
694
|
+
target[part] = {};
|
|
695
|
+
}
|
|
696
|
+
target = target[part];
|
|
697
|
+
source = value;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/server.ts
|
|
705
|
+
var TOOL_NAMES = {
|
|
706
|
+
discover: `${BRAND.nameLower}_discover`,
|
|
707
|
+
inspect: `${BRAND.nameLower}_inspect`,
|
|
708
|
+
blocks: `${BRAND.nameLower}_blocks`,
|
|
709
|
+
tokens: `${BRAND.nameLower}_tokens`,
|
|
710
|
+
implement: `${BRAND.nameLower}_implement`,
|
|
711
|
+
render: `${BRAND.nameLower}_render`,
|
|
712
|
+
fix: `${BRAND.nameLower}_fix`
|
|
713
|
+
};
|
|
714
|
+
var TOOLS = [
|
|
715
|
+
{
|
|
716
|
+
name: TOOL_NAMES.discover,
|
|
717
|
+
description: `Discover components in the design system. Use with no params to list all components. Use 'useCase' for AI-powered suggestions. Use 'component' to find alternatives. Use 'compact' for a token-efficient overview.`,
|
|
718
|
+
inputSchema: {
|
|
719
|
+
type: "object",
|
|
720
|
+
properties: {
|
|
721
|
+
useCase: {
|
|
722
|
+
type: "string",
|
|
723
|
+
description: 'Description of what you want to build \u2014 returns ranked suggestions (e.g., "form for user email input", "button to submit data")'
|
|
724
|
+
},
|
|
725
|
+
component: {
|
|
726
|
+
type: "string",
|
|
727
|
+
description: 'Component name to find alternatives for (e.g., "Button")'
|
|
728
|
+
},
|
|
729
|
+
category: {
|
|
730
|
+
type: "string",
|
|
731
|
+
description: 'Filter by category (e.g., "actions", "forms", "layout")'
|
|
732
|
+
},
|
|
733
|
+
search: {
|
|
734
|
+
type: "string",
|
|
735
|
+
description: "Search term to filter by name, description, or tags"
|
|
736
|
+
},
|
|
737
|
+
status: {
|
|
738
|
+
type: "string",
|
|
739
|
+
enum: ["stable", "beta", "deprecated", "experimental"],
|
|
740
|
+
description: "Filter by component status"
|
|
741
|
+
},
|
|
742
|
+
format: {
|
|
743
|
+
type: "string",
|
|
744
|
+
enum: ["markdown", "json"],
|
|
745
|
+
description: "Output format for context mode (default: markdown)"
|
|
746
|
+
},
|
|
747
|
+
compact: {
|
|
748
|
+
type: "boolean",
|
|
749
|
+
description: "If true, returns minimal output (just component names and categories)"
|
|
750
|
+
},
|
|
751
|
+
includeCode: {
|
|
752
|
+
type: "boolean",
|
|
753
|
+
description: "If true, includes code examples for each variant"
|
|
754
|
+
},
|
|
755
|
+
includeRelations: {
|
|
756
|
+
type: "boolean",
|
|
757
|
+
description: "If true, includes component relationships"
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
name: TOOL_NAMES.inspect,
|
|
764
|
+
description: `Get detailed information about a specific component: props, usage guidelines, code examples, accessibility \u2014 all in one call. Use 'fields' to request only specific data for token efficiency.`,
|
|
765
|
+
inputSchema: {
|
|
766
|
+
type: "object",
|
|
767
|
+
properties: {
|
|
768
|
+
component: {
|
|
769
|
+
type: "string",
|
|
770
|
+
description: 'Component name (e.g., "Button", "Input")'
|
|
771
|
+
},
|
|
772
|
+
fields: {
|
|
773
|
+
type: "array",
|
|
774
|
+
items: { type: "string" },
|
|
775
|
+
description: 'Specific fields to return (e.g., ["meta", "usage.when", "contract.propsSummary", "props", "examples", "guidelines"]). If omitted, returns everything. Supports dot notation.'
|
|
776
|
+
},
|
|
777
|
+
variant: {
|
|
778
|
+
type: "string",
|
|
779
|
+
description: 'Filter examples to a specific variant name (e.g., "Default", "Primary")'
|
|
780
|
+
},
|
|
781
|
+
maxExamples: {
|
|
782
|
+
type: "number",
|
|
783
|
+
description: "Maximum number of code examples to return (default: all)"
|
|
784
|
+
},
|
|
785
|
+
maxLines: {
|
|
786
|
+
type: "number",
|
|
787
|
+
description: "Maximum lines per code example (truncates longer examples)"
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
required: ["component"]
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
name: TOOL_NAMES.blocks,
|
|
795
|
+
description: `Search and retrieve composition blocks \u2014 named patterns showing how design system components wire together for common use cases (e.g., "Login Form", "Settings Page"). Returns the block with its code pattern.`,
|
|
796
|
+
inputSchema: {
|
|
797
|
+
type: "object",
|
|
798
|
+
properties: {
|
|
799
|
+
name: {
|
|
800
|
+
type: "string",
|
|
801
|
+
description: 'Exact block name to retrieve (e.g., "Login Form")'
|
|
802
|
+
},
|
|
803
|
+
search: {
|
|
804
|
+
type: "string",
|
|
805
|
+
description: "Free-text search across block names, descriptions, tags, and components"
|
|
806
|
+
},
|
|
807
|
+
component: {
|
|
808
|
+
type: "string",
|
|
809
|
+
description: 'Filter blocks that use a specific component (e.g., "Button")'
|
|
810
|
+
},
|
|
811
|
+
category: {
|
|
812
|
+
type: "string",
|
|
813
|
+
description: 'Filter by category (e.g., "authentication", "marketing", "dashboard", "settings", "ecommerce", "ai")'
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
name: TOOL_NAMES.tokens,
|
|
820
|
+
description: `List available CSS design tokens (custom properties) by category. Use this when you need to style custom elements or override defaults \u2014 no more guessing variable names. Filter by category or search by keyword.`,
|
|
821
|
+
inputSchema: {
|
|
822
|
+
type: "object",
|
|
823
|
+
properties: {
|
|
824
|
+
category: {
|
|
825
|
+
type: "string",
|
|
826
|
+
description: 'Filter by category (e.g., "colors", "spacing", "typography", "surfaces", "shadows", "radius", "borders", "text", "focus", "layout", "code", "component-sizing")'
|
|
827
|
+
},
|
|
828
|
+
search: {
|
|
829
|
+
type: "string",
|
|
830
|
+
description: 'Search token names (e.g., "accent", "hover", "padding")'
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
name: TOOL_NAMES.implement,
|
|
837
|
+
description: `One-shot implementation helper. Describe what you want to build and get everything needed in a single call: best-matching component(s) with full props and code examples, relevant composition blocks, and applicable CSS tokens. Saves multiple round-trips.`,
|
|
838
|
+
inputSchema: {
|
|
839
|
+
type: "object",
|
|
840
|
+
properties: {
|
|
841
|
+
useCase: {
|
|
842
|
+
type: "string",
|
|
843
|
+
description: 'What you want to implement (e.g., "login form", "data table with sorting", "streaming chat messages")'
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
required: ["useCase"]
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
name: TOOL_NAMES.render,
|
|
851
|
+
description: `Render a component and return a screenshot. Optionally compare against a Figma design ('figmaUrl'). Requires a running Fragments dev server (viewer URL). Use this to verify your implementation looks correct.`,
|
|
852
|
+
inputSchema: {
|
|
853
|
+
type: "object",
|
|
854
|
+
properties: {
|
|
855
|
+
component: {
|
|
856
|
+
type: "string",
|
|
857
|
+
description: 'Component name (e.g., "Button", "Card", "Input")'
|
|
858
|
+
},
|
|
859
|
+
props: {
|
|
860
|
+
type: "object",
|
|
861
|
+
description: 'Props to pass to the component (e.g., { "variant": "primary", "children": "Click me" })'
|
|
862
|
+
},
|
|
863
|
+
viewport: {
|
|
864
|
+
type: "object",
|
|
865
|
+
properties: {
|
|
866
|
+
width: { type: "number", description: "Viewport width (default: 800)" },
|
|
867
|
+
height: { type: "number", description: "Viewport height (default: 600)" }
|
|
868
|
+
},
|
|
869
|
+
description: "Optional viewport size for the render"
|
|
870
|
+
},
|
|
871
|
+
figmaUrl: {
|
|
872
|
+
type: "string",
|
|
873
|
+
description: "Figma frame URL \u2014 if provided, compares the render against the Figma design"
|
|
874
|
+
},
|
|
875
|
+
variant: {
|
|
876
|
+
type: "string",
|
|
877
|
+
description: "Variant name for compare mode"
|
|
878
|
+
},
|
|
879
|
+
threshold: {
|
|
880
|
+
type: "number",
|
|
881
|
+
description: "Diff threshold percentage (default: 1 for Figma)"
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
required: ["component"]
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
name: TOOL_NAMES.fix,
|
|
889
|
+
description: `Generate patches to fix token compliance issues in a component. Returns unified diff patches that replace hardcoded CSS values with design token references. Requires a running Fragments dev server.`,
|
|
890
|
+
inputSchema: {
|
|
891
|
+
type: "object",
|
|
892
|
+
properties: {
|
|
893
|
+
component: {
|
|
894
|
+
type: "string",
|
|
895
|
+
description: 'Component name to generate fixes for (e.g., "Button", "Card")'
|
|
896
|
+
},
|
|
897
|
+
variant: {
|
|
898
|
+
type: "string",
|
|
899
|
+
description: "Specific variant to fix (optional, fixes all variants if omitted)"
|
|
900
|
+
},
|
|
901
|
+
fixType: {
|
|
902
|
+
type: "string",
|
|
903
|
+
enum: ["token", "all"],
|
|
904
|
+
description: 'Type of fixes to generate: "token" for hardcoded\u2192token replacements, "all" for all available fixes (default: "all")'
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
required: ["component"]
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
];
|
|
911
|
+
function createMcpServer(config) {
|
|
912
|
+
const server = new Server(
|
|
913
|
+
{
|
|
914
|
+
name: `${BRAND.nameLower}-mcp`,
|
|
915
|
+
version: "0.3.0"
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
capabilities: {
|
|
919
|
+
tools: {}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
);
|
|
923
|
+
let segmentsData = null;
|
|
924
|
+
const segmentPackageMap = /* @__PURE__ */ new Map();
|
|
925
|
+
let defaultPackageName = null;
|
|
926
|
+
async function loadSegments() {
|
|
927
|
+
if (segmentsData) return segmentsData;
|
|
928
|
+
const paths = findFragmentsJson(config.projectRoot);
|
|
929
|
+
if (paths.length === 0) {
|
|
930
|
+
throw new Error(
|
|
931
|
+
`No ${BRAND.outFile} found. Searched ${config.projectRoot} and package.json dependencies. Either run \`${BRAND.cliCommand} build\` or install a package with a "fragments" field in its package.json.`
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
const content = await readFile(paths[0], "utf-8");
|
|
935
|
+
segmentsData = JSON.parse(content);
|
|
936
|
+
if (!segmentsData.blocks && segmentsData.recipes) {
|
|
937
|
+
segmentsData.blocks = segmentsData.recipes;
|
|
938
|
+
}
|
|
939
|
+
if (segmentsData.packageName) {
|
|
940
|
+
for (const name of Object.keys(segmentsData.segments)) {
|
|
941
|
+
segmentPackageMap.set(name, segmentsData.packageName);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
for (let i = 1; i < paths.length; i++) {
|
|
945
|
+
const extra = JSON.parse(await readFile(paths[i], "utf-8"));
|
|
946
|
+
if (extra.packageName) {
|
|
947
|
+
for (const name of Object.keys(extra.segments)) {
|
|
948
|
+
segmentPackageMap.set(name, extra.packageName);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
Object.assign(segmentsData.segments, extra.segments);
|
|
952
|
+
const extraBlocks = extra.blocks ?? extra.recipes;
|
|
953
|
+
if (extraBlocks) {
|
|
954
|
+
segmentsData.blocks = { ...segmentsData.blocks, ...extraBlocks };
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return segmentsData;
|
|
958
|
+
}
|
|
959
|
+
async function getPackageName(segmentName) {
|
|
960
|
+
await loadSegments();
|
|
961
|
+
if (segmentName) {
|
|
962
|
+
const segPkg = segmentPackageMap.get(segmentName);
|
|
963
|
+
if (segPkg) return segPkg;
|
|
964
|
+
}
|
|
965
|
+
if (defaultPackageName) return defaultPackageName;
|
|
966
|
+
if (segmentsData?.packageName) {
|
|
967
|
+
defaultPackageName = segmentsData.packageName;
|
|
968
|
+
return defaultPackageName;
|
|
969
|
+
}
|
|
970
|
+
const packageJsonPath = join2(config.projectRoot, "package.json");
|
|
971
|
+
if (existsSync2(packageJsonPath)) {
|
|
972
|
+
try {
|
|
973
|
+
const content = readFileSync2(packageJsonPath, "utf-8");
|
|
974
|
+
const pkg = JSON.parse(content);
|
|
975
|
+
if (pkg.name) {
|
|
976
|
+
defaultPackageName = pkg.name;
|
|
977
|
+
return defaultPackageName;
|
|
978
|
+
}
|
|
979
|
+
} catch {
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
defaultPackageName = "your-component-library";
|
|
983
|
+
return defaultPackageName;
|
|
984
|
+
}
|
|
985
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
986
|
+
return { tools: TOOLS };
|
|
987
|
+
});
|
|
988
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
989
|
+
const { name, arguments: args2 } = request.params;
|
|
990
|
+
try {
|
|
991
|
+
switch (name) {
|
|
992
|
+
// ================================================================
|
|
993
|
+
// DISCOVER — list, suggest, context, alternatives
|
|
994
|
+
// ================================================================
|
|
995
|
+
case TOOL_NAMES.discover: {
|
|
996
|
+
const data = await loadSegments();
|
|
997
|
+
const useCase = args2?.useCase ?? void 0;
|
|
998
|
+
const componentForAlts = args2?.component ?? void 0;
|
|
999
|
+
const category = args2?.category ?? void 0;
|
|
1000
|
+
const search = args2?.search?.toLowerCase() ?? void 0;
|
|
1001
|
+
const status = args2?.status ?? void 0;
|
|
1002
|
+
const format = args2?.format ?? "markdown";
|
|
1003
|
+
const compact = args2?.compact ?? false;
|
|
1004
|
+
const includeCode = args2?.includeCode ?? false;
|
|
1005
|
+
const includeRelations = args2?.includeRelations ?? false;
|
|
1006
|
+
if (compact || args2?.format && !useCase && !componentForAlts && !category && !search && !status) {
|
|
1007
|
+
const segments2 = Object.values(data.segments);
|
|
1008
|
+
const allBlocks = Object.values(data.blocks ?? data.recipes ?? {});
|
|
1009
|
+
const { content: ctxContent, tokenEstimate } = generateContext(segments2, {
|
|
1010
|
+
format,
|
|
1011
|
+
compact,
|
|
1012
|
+
include: {
|
|
1013
|
+
code: includeCode,
|
|
1014
|
+
relations: includeRelations
|
|
1015
|
+
}
|
|
1016
|
+
}, allBlocks);
|
|
1017
|
+
return {
|
|
1018
|
+
content: [{ type: "text", text: ctxContent }],
|
|
1019
|
+
_meta: { tokenEstimate }
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
if (useCase) {
|
|
1023
|
+
const allSegments = Object.values(data.segments);
|
|
1024
|
+
const allBlocks = Object.values(data.blocks ?? data.recipes ?? {});
|
|
1025
|
+
const context = args2?.context?.toLowerCase() ?? "";
|
|
1026
|
+
const fullQuery = context ? `${useCase} ${context}` : useCase;
|
|
1027
|
+
const localData = {
|
|
1028
|
+
segments: allSegments,
|
|
1029
|
+
blocks: allBlocks,
|
|
1030
|
+
tokenData: data.tokens
|
|
1031
|
+
};
|
|
1032
|
+
const searchResults = await hybridSearch(fullQuery, localData, 10, "component");
|
|
1033
|
+
const scored = searchResults.map((result) => {
|
|
1034
|
+
const segment = allSegments.find(
|
|
1035
|
+
(s) => s.meta.name.toLowerCase() === result.name.toLowerCase()
|
|
1036
|
+
);
|
|
1037
|
+
if (!segment) return null;
|
|
1038
|
+
const filteredWhen = filterPlaceholders(segment.usage?.when).slice(0, 3);
|
|
1039
|
+
const filteredWhenNot = filterPlaceholders(segment.usage?.whenNot).slice(0, 2);
|
|
1040
|
+
let confidence;
|
|
1041
|
+
if (result.score >= 0.025) confidence = "high";
|
|
1042
|
+
else if (result.score >= 0.015) confidence = "medium";
|
|
1043
|
+
else confidence = "low";
|
|
1044
|
+
return {
|
|
1045
|
+
component: segment.meta.name,
|
|
1046
|
+
category: segment.meta.category,
|
|
1047
|
+
description: segment.meta.description,
|
|
1048
|
+
confidence,
|
|
1049
|
+
reasons: [`Matched via hybrid search (score: ${result.score.toFixed(4)})`],
|
|
1050
|
+
usage: { when: filteredWhen, whenNot: filteredWhenNot },
|
|
1051
|
+
variantCount: segment.variants.length,
|
|
1052
|
+
status: segment.meta.status
|
|
1053
|
+
};
|
|
1054
|
+
}).filter(Boolean);
|
|
1055
|
+
const suggestions = [];
|
|
1056
|
+
const categoryCount = {};
|
|
1057
|
+
for (const item of scored) {
|
|
1058
|
+
if (!item) continue;
|
|
1059
|
+
const cat = item.category || "uncategorized";
|
|
1060
|
+
const count = categoryCount[cat] || 0;
|
|
1061
|
+
if (count < 2 || suggestions.length < 3) {
|
|
1062
|
+
suggestions.push(item);
|
|
1063
|
+
categoryCount[cat] = count + 1;
|
|
1064
|
+
if (suggestions.length >= 5) break;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
const compositionHint = suggestions.length >= 2 ? `These components work well together. For example, ${suggestions[0].component} can be combined with ${suggestions.slice(1, 3).map((s) => s.component).join(" and ")}.` : void 0;
|
|
1068
|
+
const useCaseLower = useCase.toLowerCase();
|
|
1069
|
+
const STYLE_KEYWORDS = ["color", "spacing", "padding", "margin", "font", "border", "radius", "shadow", "variable", "token", "css", "theme", "dark mode", "background", "hover"];
|
|
1070
|
+
const isStyleQuery = STYLE_KEYWORDS.some((kw) => useCaseLower.includes(kw));
|
|
1071
|
+
const noMatch = suggestions.length === 0;
|
|
1072
|
+
const weakMatch = !noMatch && suggestions.every((s) => s.confidence === "low");
|
|
1073
|
+
let recommendation;
|
|
1074
|
+
let nextStep;
|
|
1075
|
+
if (noMatch) {
|
|
1076
|
+
recommendation = isStyleQuery ? `No matching components found. Your query seems styling-related \u2014 try ${TOOL_NAMES.tokens} to find CSS custom properties.` : "No matching components found. Try different keywords or browse all components with fragments_discover.";
|
|
1077
|
+
nextStep = isStyleQuery ? `Use ${TOOL_NAMES.tokens}(search: "${useCaseLower.split(/\s+/)[0]}") to find design tokens.` : void 0;
|
|
1078
|
+
} else if (weakMatch) {
|
|
1079
|
+
recommendation = `Weak matches only \u2014 ${suggestions[0].component} might work but confidence is low.${isStyleQuery ? ` If you need a CSS variable, try ${TOOL_NAMES.tokens}.` : ""}`;
|
|
1080
|
+
nextStep = `Use ${TOOL_NAMES.inspect}("${suggestions[0].component}") to check if it fits, or try broader search terms.`;
|
|
1081
|
+
} else {
|
|
1082
|
+
recommendation = `Best match: ${suggestions[0].component} (${suggestions[0].confidence} confidence) - ${suggestions[0].description}`;
|
|
1083
|
+
nextStep = `Use ${TOOL_NAMES.inspect}("${suggestions[0].component}") for full details.`;
|
|
1084
|
+
}
|
|
1085
|
+
return {
|
|
1086
|
+
content: [{
|
|
1087
|
+
type: "text",
|
|
1088
|
+
text: JSON.stringify({
|
|
1089
|
+
useCase,
|
|
1090
|
+
context: context || void 0,
|
|
1091
|
+
suggestions,
|
|
1092
|
+
noMatch,
|
|
1093
|
+
weakMatch,
|
|
1094
|
+
recommendation,
|
|
1095
|
+
compositionHint,
|
|
1096
|
+
nextStep
|
|
1097
|
+
}, null, 2)
|
|
1098
|
+
}]
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
if (componentForAlts) {
|
|
1102
|
+
const segment = Object.values(data.segments).find(
|
|
1103
|
+
(s) => s.meta.name.toLowerCase() === componentForAlts.toLowerCase()
|
|
1104
|
+
);
|
|
1105
|
+
if (!segment) {
|
|
1106
|
+
throw new Error(`Component "${componentForAlts}" not found. Use fragments_discover to see available components.`);
|
|
1107
|
+
}
|
|
1108
|
+
const relations = segment.relations ?? [];
|
|
1109
|
+
const referencedBy = Object.values(data.segments).filter(
|
|
1110
|
+
(s) => s.relations?.some((r) => r.component.toLowerCase() === componentForAlts.toLowerCase())
|
|
1111
|
+
).map((s) => ({
|
|
1112
|
+
component: s.meta.name,
|
|
1113
|
+
relationship: s.relations?.find(
|
|
1114
|
+
(r) => r.component.toLowerCase() === componentForAlts.toLowerCase()
|
|
1115
|
+
)?.relationship,
|
|
1116
|
+
note: s.relations?.find(
|
|
1117
|
+
(r) => r.component.toLowerCase() === componentForAlts.toLowerCase()
|
|
1118
|
+
)?.note
|
|
1119
|
+
}));
|
|
1120
|
+
const sameCategory = Object.values(data.segments).filter(
|
|
1121
|
+
(s) => s.meta.category === segment.meta.category && s.meta.name.toLowerCase() !== componentForAlts.toLowerCase()
|
|
1122
|
+
).map((s) => ({
|
|
1123
|
+
component: s.meta.name,
|
|
1124
|
+
description: s.meta.description
|
|
1125
|
+
}));
|
|
1126
|
+
return {
|
|
1127
|
+
content: [{
|
|
1128
|
+
type: "text",
|
|
1129
|
+
text: JSON.stringify({
|
|
1130
|
+
component: segment.meta.name,
|
|
1131
|
+
category: segment.meta.category,
|
|
1132
|
+
directRelations: relations,
|
|
1133
|
+
referencedBy,
|
|
1134
|
+
sameCategory,
|
|
1135
|
+
suggestion: relations.find((r) => r.relationship === "alternative") ? `Consider ${relations.find((r) => r.relationship === "alternative")?.component}: ${relations.find((r) => r.relationship === "alternative")?.note}` : void 0
|
|
1136
|
+
}, null, 2)
|
|
1137
|
+
}]
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
const segments = Object.values(data.segments).filter((s) => {
|
|
1141
|
+
if (category && s.meta.category !== category) return false;
|
|
1142
|
+
if (status && (s.meta.status ?? "stable") !== status) return false;
|
|
1143
|
+
if (search) {
|
|
1144
|
+
const nameMatch = s.meta.name.toLowerCase().includes(search);
|
|
1145
|
+
const descMatch = s.meta.description?.toLowerCase().includes(search);
|
|
1146
|
+
const tagMatch = s.meta.tags?.some((t) => t.toLowerCase().includes(search));
|
|
1147
|
+
if (!nameMatch && !descMatch && !tagMatch) return false;
|
|
1148
|
+
}
|
|
1149
|
+
return true;
|
|
1150
|
+
}).map((s) => ({
|
|
1151
|
+
name: s.meta.name,
|
|
1152
|
+
category: s.meta.category,
|
|
1153
|
+
description: s.meta.description,
|
|
1154
|
+
status: s.meta.status ?? "stable",
|
|
1155
|
+
variantCount: s.variants.length,
|
|
1156
|
+
tags: s.meta.tags ?? []
|
|
1157
|
+
}));
|
|
1158
|
+
return {
|
|
1159
|
+
content: [{
|
|
1160
|
+
type: "text",
|
|
1161
|
+
text: JSON.stringify({
|
|
1162
|
+
total: segments.length,
|
|
1163
|
+
segments,
|
|
1164
|
+
categories: [...new Set(segments.map((s) => s.category))],
|
|
1165
|
+
hint: segments.length === 0 ? "No components found. Try broader search terms or check available categories." : segments.length > 5 ? "Use fragments_discover with useCase for recommendations, or fragments_inspect for details on a specific component." : void 0
|
|
1166
|
+
}, null, 2)
|
|
1167
|
+
}]
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
// ================================================================
|
|
1171
|
+
// INSPECT
|
|
1172
|
+
// ================================================================
|
|
1173
|
+
case TOOL_NAMES.inspect: {
|
|
1174
|
+
const data = await loadSegments();
|
|
1175
|
+
const componentName = args2?.component;
|
|
1176
|
+
const fields = args2?.fields;
|
|
1177
|
+
const variantName = args2?.variant ?? void 0;
|
|
1178
|
+
const maxExamples = args2?.maxExamples;
|
|
1179
|
+
const maxLines = args2?.maxLines;
|
|
1180
|
+
if (!componentName) {
|
|
1181
|
+
throw new Error("component is required");
|
|
1182
|
+
}
|
|
1183
|
+
const segment = Object.values(data.segments).find(
|
|
1184
|
+
(s) => s.meta.name.toLowerCase() === componentName.toLowerCase()
|
|
1185
|
+
);
|
|
1186
|
+
if (!segment) {
|
|
1187
|
+
throw new Error(`Component "${componentName}" not found. Use fragments_discover to see available components.`);
|
|
1188
|
+
}
|
|
1189
|
+
const pkgName = await getPackageName(segment.meta.name);
|
|
1190
|
+
let variants = segment.variants;
|
|
1191
|
+
if (variantName) {
|
|
1192
|
+
const query = variantName.toLowerCase();
|
|
1193
|
+
let filtered = variants.filter((v) => v.name.toLowerCase() === query);
|
|
1194
|
+
if (filtered.length === 0) {
|
|
1195
|
+
filtered = variants.filter((v) => v.name.toLowerCase().startsWith(query));
|
|
1196
|
+
}
|
|
1197
|
+
if (filtered.length === 0) {
|
|
1198
|
+
filtered = variants.filter((v) => v.name.toLowerCase().includes(query));
|
|
1199
|
+
}
|
|
1200
|
+
if (filtered.length > 0) {
|
|
1201
|
+
variants = filtered;
|
|
1202
|
+
} else {
|
|
1203
|
+
throw new Error(
|
|
1204
|
+
`Variant "${variantName}" not found for ${componentName}. Available: ${segment.variants.map((v) => v.name).join(", ")}`
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (maxExamples && maxExamples > 0) {
|
|
1209
|
+
variants = variants.slice(0, maxExamples);
|
|
1210
|
+
}
|
|
1211
|
+
const truncateCode = (code) => {
|
|
1212
|
+
if (!maxLines || maxLines <= 0) return code;
|
|
1213
|
+
const lines = code.split("\n");
|
|
1214
|
+
if (lines.length <= maxLines) return code;
|
|
1215
|
+
return lines.slice(0, maxLines).join("\n") + "\n// ... truncated";
|
|
1216
|
+
};
|
|
1217
|
+
const examples = variants.map((variant) => {
|
|
1218
|
+
if (variant.code) {
|
|
1219
|
+
return {
|
|
1220
|
+
variant: variant.name,
|
|
1221
|
+
description: variant.description,
|
|
1222
|
+
code: truncateCode(variant.code)
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
variant: variant.name,
|
|
1227
|
+
description: variant.description,
|
|
1228
|
+
code: `<${segment.meta.name} />`,
|
|
1229
|
+
note: "No code example provided in fragment. Refer to props for customization."
|
|
1230
|
+
};
|
|
1231
|
+
});
|
|
1232
|
+
const propsReference = Object.entries(segment.props ?? {}).map(([propName, prop]) => ({
|
|
1233
|
+
name: propName,
|
|
1234
|
+
type: prop.type,
|
|
1235
|
+
required: prop.required,
|
|
1236
|
+
default: prop.default,
|
|
1237
|
+
description: prop.description
|
|
1238
|
+
}));
|
|
1239
|
+
const propConstraints = Object.entries(segment.props ?? {}).filter(([, prop]) => prop.constraints && prop.constraints.length > 0).map(([pName, prop]) => ({
|
|
1240
|
+
prop: pName,
|
|
1241
|
+
constraints: prop.constraints
|
|
1242
|
+
}));
|
|
1243
|
+
const fullResult = {
|
|
1244
|
+
meta: segment.meta,
|
|
1245
|
+
props: segment.props,
|
|
1246
|
+
variants: segment.variants,
|
|
1247
|
+
relations: segment.relations,
|
|
1248
|
+
contract: segment.contract,
|
|
1249
|
+
generated: segment._generated,
|
|
1250
|
+
guidelines: {
|
|
1251
|
+
when: filterPlaceholders(segment.usage?.when),
|
|
1252
|
+
whenNot: filterPlaceholders(segment.usage?.whenNot),
|
|
1253
|
+
guidelines: segment.usage?.guidelines ?? [],
|
|
1254
|
+
accessibility: segment.usage?.accessibility ?? [],
|
|
1255
|
+
propConstraints,
|
|
1256
|
+
alternatives: segment.relations?.filter((r) => r.relationship === "alternative").map((r) => ({
|
|
1257
|
+
component: r.component,
|
|
1258
|
+
note: r.note
|
|
1259
|
+
})) ?? []
|
|
1260
|
+
},
|
|
1261
|
+
examples: {
|
|
1262
|
+
import: `import { ${segment.meta.name} } from '${pkgName}';`,
|
|
1263
|
+
code: examples,
|
|
1264
|
+
propsReference
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
const result = fields && fields.length > 0 ? projectFields(fullResult, fields) : fullResult;
|
|
1268
|
+
return {
|
|
1269
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
// ================================================================
|
|
1273
|
+
// BLOCKS
|
|
1274
|
+
// ================================================================
|
|
1275
|
+
case TOOL_NAMES.blocks: {
|
|
1276
|
+
const data = await loadSegments();
|
|
1277
|
+
const blockName = args2?.name;
|
|
1278
|
+
const search = args2?.search?.toLowerCase() ?? void 0;
|
|
1279
|
+
const component = args2?.component?.toLowerCase() ?? void 0;
|
|
1280
|
+
const category = args2?.category?.toLowerCase() ?? void 0;
|
|
1281
|
+
const allBlocks = Object.values(data.blocks ?? data.recipes ?? {});
|
|
1282
|
+
if (allBlocks.length === 0) {
|
|
1283
|
+
return {
|
|
1284
|
+
content: [{
|
|
1285
|
+
type: "text",
|
|
1286
|
+
text: JSON.stringify({
|
|
1287
|
+
total: 0,
|
|
1288
|
+
blocks: [],
|
|
1289
|
+
hint: `No blocks found. Run \`${BRAND.cliCommand} build\` after adding .block.ts files.`
|
|
1290
|
+
}, null, 2)
|
|
1291
|
+
}]
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
let filtered = allBlocks;
|
|
1295
|
+
if (blockName) {
|
|
1296
|
+
filtered = filtered.filter(
|
|
1297
|
+
(b) => b.name.toLowerCase() === blockName.toLowerCase()
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
if (search) {
|
|
1301
|
+
filtered = filtered.filter((b) => {
|
|
1302
|
+
const haystack = [
|
|
1303
|
+
b.name,
|
|
1304
|
+
b.description,
|
|
1305
|
+
...b.tags ?? [],
|
|
1306
|
+
...b.components,
|
|
1307
|
+
b.category
|
|
1308
|
+
].join(" ").toLowerCase();
|
|
1309
|
+
return haystack.includes(search);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
if (component) {
|
|
1313
|
+
filtered = filtered.filter(
|
|
1314
|
+
(b) => b.components.some((c) => c.toLowerCase() === component)
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
if (category) {
|
|
1318
|
+
filtered = filtered.filter(
|
|
1319
|
+
(b) => b.category.toLowerCase() === category
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
return {
|
|
1323
|
+
content: [{
|
|
1324
|
+
type: "text",
|
|
1325
|
+
text: JSON.stringify({
|
|
1326
|
+
total: filtered.length,
|
|
1327
|
+
blocks: filtered
|
|
1328
|
+
}, null, 2)
|
|
1329
|
+
}]
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
// ================================================================
|
|
1333
|
+
// TOKENS
|
|
1334
|
+
// ================================================================
|
|
1335
|
+
case TOOL_NAMES.tokens: {
|
|
1336
|
+
const data = await loadSegments();
|
|
1337
|
+
const category = args2?.category?.toLowerCase() ?? void 0;
|
|
1338
|
+
const search = args2?.search?.toLowerCase() ?? void 0;
|
|
1339
|
+
const tokenData = data.tokens;
|
|
1340
|
+
if (!tokenData || tokenData.total === 0) {
|
|
1341
|
+
return {
|
|
1342
|
+
content: [{
|
|
1343
|
+
type: "text",
|
|
1344
|
+
text: JSON.stringify({
|
|
1345
|
+
total: 0,
|
|
1346
|
+
categories: {},
|
|
1347
|
+
hint: `No design tokens found. Add a tokens.include pattern to your ${BRAND.configFile} and run \`${BRAND.cliCommand} build\`.`
|
|
1348
|
+
}, null, 2)
|
|
1349
|
+
}]
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
let filteredCategories = {};
|
|
1353
|
+
let filteredTotal = 0;
|
|
1354
|
+
for (const [cat, tokens] of Object.entries(tokenData.categories)) {
|
|
1355
|
+
if (category && cat !== category) continue;
|
|
1356
|
+
let filtered = tokens;
|
|
1357
|
+
if (search) {
|
|
1358
|
+
filtered = tokens.filter(
|
|
1359
|
+
(t) => t.name.toLowerCase().includes(search) || t.description && t.description.toLowerCase().includes(search)
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
if (filtered.length > 0) {
|
|
1363
|
+
filteredCategories[cat] = filtered;
|
|
1364
|
+
filteredTotal += filtered.length;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
let hint;
|
|
1368
|
+
if (filteredTotal === 0) {
|
|
1369
|
+
const availableCategories = Object.keys(tokenData.categories);
|
|
1370
|
+
hint = search ? `No tokens matching "${search}". Try: ${availableCategories.join(", ")}` : category ? `Category "${category}" not found. Available: ${availableCategories.join(", ")}` : void 0;
|
|
1371
|
+
} else if (!category && !search) {
|
|
1372
|
+
hint = `Use var(--token-name) in your CSS/styles. Filter by category or search to narrow results.`;
|
|
1373
|
+
}
|
|
1374
|
+
return {
|
|
1375
|
+
content: [{
|
|
1376
|
+
type: "text",
|
|
1377
|
+
text: JSON.stringify({
|
|
1378
|
+
prefix: tokenData.prefix,
|
|
1379
|
+
total: filteredTotal,
|
|
1380
|
+
totalAvailable: tokenData.total,
|
|
1381
|
+
categories: filteredCategories,
|
|
1382
|
+
...hint && { hint },
|
|
1383
|
+
...!category && !search && {
|
|
1384
|
+
availableCategories: Object.entries(tokenData.categories).map(
|
|
1385
|
+
([cat, tokens]) => ({ category: cat, count: tokens.length })
|
|
1386
|
+
)
|
|
1387
|
+
}
|
|
1388
|
+
}, null, 2)
|
|
1389
|
+
}]
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
// ================================================================
|
|
1393
|
+
// IMPLEMENT — one-shot discover + inspect + blocks + tokens
|
|
1394
|
+
// ================================================================
|
|
1395
|
+
case TOOL_NAMES.implement: {
|
|
1396
|
+
const data = await loadSegments();
|
|
1397
|
+
const useCase = args2?.useCase;
|
|
1398
|
+
if (!useCase) {
|
|
1399
|
+
throw new Error("useCase is required");
|
|
1400
|
+
}
|
|
1401
|
+
const allSegments = Object.values(data.segments);
|
|
1402
|
+
const allBlocks = Object.values(data.blocks ?? data.recipes ?? {});
|
|
1403
|
+
const tokenData = data.tokens;
|
|
1404
|
+
const localData = {
|
|
1405
|
+
segments: allSegments,
|
|
1406
|
+
blocks: allBlocks,
|
|
1407
|
+
tokenData
|
|
1408
|
+
};
|
|
1409
|
+
const searchResults = await hybridSearch(useCase, localData, 15);
|
|
1410
|
+
const componentResults = searchResults.filter((r) => r.kind === "component").slice(0, 3);
|
|
1411
|
+
const blockResults = searchResults.filter((r) => r.kind === "block").slice(0, 2);
|
|
1412
|
+
const tokenResults = searchResults.filter((r) => r.kind === "token").slice(0, 5);
|
|
1413
|
+
const topMatches = componentResults.map((result) => {
|
|
1414
|
+
const segment = allSegments.find(
|
|
1415
|
+
(s) => s.meta.name.toLowerCase() === result.name.toLowerCase()
|
|
1416
|
+
);
|
|
1417
|
+
return segment ? { segment, score: result.score } : null;
|
|
1418
|
+
}).filter(Boolean);
|
|
1419
|
+
const components = await Promise.all(
|
|
1420
|
+
topMatches.map(async ({ segment: s, score }) => {
|
|
1421
|
+
const pkgName = await getPackageName(s.meta.name);
|
|
1422
|
+
const examples = s.variants.slice(0, 2).map((v) => ({
|
|
1423
|
+
variant: v.name,
|
|
1424
|
+
code: v.code ?? `<${s.meta.name} />`
|
|
1425
|
+
}));
|
|
1426
|
+
const propsSummary = Object.entries(s.props ?? {}).slice(0, 10).map(
|
|
1427
|
+
([pName, p]) => `${pName}${p.required ? " (required)" : ""}: ${p.type}${p.values ? ` = ${p.values.join("|")}` : ""}`
|
|
1428
|
+
);
|
|
1429
|
+
return {
|
|
1430
|
+
name: s.meta.name,
|
|
1431
|
+
category: s.meta.category,
|
|
1432
|
+
description: s.meta.description,
|
|
1433
|
+
confidence: score >= 0.025 ? "high" : score >= 0.015 ? "medium" : "low",
|
|
1434
|
+
import: `import { ${s.meta.name} } from '${pkgName}';`,
|
|
1435
|
+
props: propsSummary,
|
|
1436
|
+
examples,
|
|
1437
|
+
guidelines: filterPlaceholders(s.usage?.when).slice(0, 3),
|
|
1438
|
+
accessibility: s.usage?.accessibility?.slice(0, 2) ?? []
|
|
1439
|
+
};
|
|
1440
|
+
})
|
|
1441
|
+
);
|
|
1442
|
+
const matchingBlocks = blockResults.map((result) => {
|
|
1443
|
+
const block = allBlocks.find(
|
|
1444
|
+
(b) => b.name.toLowerCase() === result.name.toLowerCase()
|
|
1445
|
+
);
|
|
1446
|
+
return block ? { name: block.name, description: block.description, components: block.components, code: block.code } : null;
|
|
1447
|
+
}).filter(Boolean);
|
|
1448
|
+
let relevantTokens;
|
|
1449
|
+
if (tokenResults.length > 0 && tokenData) {
|
|
1450
|
+
relevantTokens = {};
|
|
1451
|
+
for (const result of tokenResults) {
|
|
1452
|
+
for (const [cat, tokens] of Object.entries(tokenData.categories)) {
|
|
1453
|
+
if (tokens.some((t) => t.name === result.name)) {
|
|
1454
|
+
if (!relevantTokens[cat]) relevantTokens[cat] = [];
|
|
1455
|
+
relevantTokens[cat].push(result.name);
|
|
1456
|
+
break;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (Object.keys(relevantTokens).length === 0) relevantTokens = void 0;
|
|
1461
|
+
}
|
|
1462
|
+
return {
|
|
1463
|
+
content: [{
|
|
1464
|
+
type: "text",
|
|
1465
|
+
text: JSON.stringify({
|
|
1466
|
+
useCase,
|
|
1467
|
+
components,
|
|
1468
|
+
blocks: matchingBlocks.length > 0 ? matchingBlocks : void 0,
|
|
1469
|
+
tokens: relevantTokens,
|
|
1470
|
+
noMatch: components.length === 0,
|
|
1471
|
+
summary: components.length > 0 ? `Found ${components.length} component(s) for "${useCase}". ${matchingBlocks.length > 0 ? `Plus ${matchingBlocks.length} ready-to-use block(s).` : ""}` : `No components match "${useCase}". Try ${TOOL_NAMES.discover} with different terms${tokenData ? ` or ${TOOL_NAMES.tokens} for CSS variables` : ""}.`
|
|
1472
|
+
}, null, 2)
|
|
1473
|
+
}]
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
// ================================================================
|
|
1477
|
+
// RENDER — HTTP-only (no Playwright)
|
|
1478
|
+
// ================================================================
|
|
1479
|
+
case TOOL_NAMES.render: {
|
|
1480
|
+
const componentName = args2?.component;
|
|
1481
|
+
const variantName = args2?.variant;
|
|
1482
|
+
const props = args2?.props ?? {};
|
|
1483
|
+
const viewport = args2?.viewport;
|
|
1484
|
+
const figmaUrl = args2?.figmaUrl;
|
|
1485
|
+
const threshold = args2?.threshold ?? (figmaUrl ? 1 : config.threshold ?? DEFAULTS.diffThreshold);
|
|
1486
|
+
if (!componentName) {
|
|
1487
|
+
return {
|
|
1488
|
+
content: [{ type: "text", text: "Error: component name is required" }],
|
|
1489
|
+
isError: true
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
const viewerUrl2 = config.viewerUrl;
|
|
1493
|
+
if (!viewerUrl2) {
|
|
1494
|
+
return {
|
|
1495
|
+
content: [{
|
|
1496
|
+
type: "text",
|
|
1497
|
+
text: "Render requires a running Fragments dev server. Start it with `fragments dev` and pass --viewer-url, or use the CLI MCP server which includes Playwright."
|
|
1498
|
+
}],
|
|
1499
|
+
isError: true
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
if (figmaUrl) {
|
|
1503
|
+
try {
|
|
1504
|
+
const result = await compareComponent(viewerUrl2, {
|
|
1505
|
+
component: componentName,
|
|
1506
|
+
variant: variantName,
|
|
1507
|
+
props,
|
|
1508
|
+
figmaUrl,
|
|
1509
|
+
threshold
|
|
1510
|
+
});
|
|
1511
|
+
if (result.error) {
|
|
1512
|
+
return {
|
|
1513
|
+
content: [{
|
|
1514
|
+
type: "text",
|
|
1515
|
+
text: `Compare error: ${result.error}${result.suggestion ? `
|
|
1516
|
+
Suggestion: ${result.suggestion}` : ""}`
|
|
1517
|
+
}],
|
|
1518
|
+
isError: true
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
const content = [];
|
|
1522
|
+
const summaryText = result.match ? `MATCH: ${componentName} matches Figma design (${result.diffPercentage}% diff, threshold: ${result.threshold}%)` : `MISMATCH: ${componentName} differs from Figma design by ${result.diffPercentage}% (threshold: ${result.threshold}%)`;
|
|
1523
|
+
content.push({ type: "text", text: summaryText });
|
|
1524
|
+
if (result.diff && !result.match) {
|
|
1525
|
+
content.push({
|
|
1526
|
+
type: "image",
|
|
1527
|
+
data: result.diff.replace("data:image/png;base64,", ""),
|
|
1528
|
+
mimeType: "image/png"
|
|
1529
|
+
});
|
|
1530
|
+
content.push({
|
|
1531
|
+
type: "text",
|
|
1532
|
+
text: `Diff image above shows visual differences (red highlights). Changed regions: ${result.changedRegions?.length ?? 0}`
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
content.push({
|
|
1536
|
+
type: "text",
|
|
1537
|
+
text: JSON.stringify({
|
|
1538
|
+
match: result.match,
|
|
1539
|
+
diffPercentage: result.diffPercentage,
|
|
1540
|
+
threshold: result.threshold,
|
|
1541
|
+
figmaUrl: result.figmaUrl,
|
|
1542
|
+
changedRegions: result.changedRegions
|
|
1543
|
+
}, null, 2)
|
|
1544
|
+
});
|
|
1545
|
+
return { content };
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
return {
|
|
1548
|
+
content: [{
|
|
1549
|
+
type: "text",
|
|
1550
|
+
text: `Failed to compare component: ${error instanceof Error ? error.message : "Unknown error"}. Make sure the Fragments dev server is running and FIGMA_ACCESS_TOKEN is set.`
|
|
1551
|
+
}],
|
|
1552
|
+
isError: true
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
try {
|
|
1557
|
+
const result = await renderComponent(viewerUrl2, {
|
|
1558
|
+
component: componentName,
|
|
1559
|
+
props,
|
|
1560
|
+
viewport: viewport ?? { width: 800, height: 600 }
|
|
1561
|
+
});
|
|
1562
|
+
if (result.error) {
|
|
1563
|
+
return {
|
|
1564
|
+
content: [{ type: "text", text: `Render error: ${result.error}` }],
|
|
1565
|
+
isError: true
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
content: [
|
|
1570
|
+
{
|
|
1571
|
+
type: "image",
|
|
1572
|
+
data: result.screenshot.replace("data:image/png;base64,", ""),
|
|
1573
|
+
mimeType: "image/png"
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
type: "text",
|
|
1577
|
+
text: `Successfully rendered ${componentName} with props: ${JSON.stringify(props)}`
|
|
1578
|
+
}
|
|
1579
|
+
]
|
|
1580
|
+
};
|
|
1581
|
+
} catch (error) {
|
|
1582
|
+
return {
|
|
1583
|
+
content: [{
|
|
1584
|
+
type: "text",
|
|
1585
|
+
text: `Failed to render component: ${error instanceof Error ? error.message : "Unknown error"}. Make sure the Fragments dev server is running.`
|
|
1586
|
+
}],
|
|
1587
|
+
isError: true
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
// ================================================================
|
|
1592
|
+
// FIX — HTTP-only (no Playwright)
|
|
1593
|
+
// ================================================================
|
|
1594
|
+
case TOOL_NAMES.fix: {
|
|
1595
|
+
const data = await loadSegments();
|
|
1596
|
+
const componentName = args2?.component;
|
|
1597
|
+
const variantName = args2?.variant ?? void 0;
|
|
1598
|
+
const fixType = args2?.fixType ?? "all";
|
|
1599
|
+
if (!componentName) {
|
|
1600
|
+
throw new Error("component is required");
|
|
1601
|
+
}
|
|
1602
|
+
const segment = Object.values(data.segments).find(
|
|
1603
|
+
(s) => s.meta.name.toLowerCase() === componentName.toLowerCase()
|
|
1604
|
+
);
|
|
1605
|
+
if (!segment) {
|
|
1606
|
+
throw new Error(`Component "${componentName}" not found. Use fragments_discover to see available components.`);
|
|
1607
|
+
}
|
|
1608
|
+
const viewerUrl2 = config.viewerUrl;
|
|
1609
|
+
if (!viewerUrl2) {
|
|
1610
|
+
return {
|
|
1611
|
+
content: [{
|
|
1612
|
+
type: "text",
|
|
1613
|
+
text: "Fix requires a running Fragments dev server. Start it with `fragments dev` and pass --viewer-url."
|
|
1614
|
+
}],
|
|
1615
|
+
isError: true
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
try {
|
|
1619
|
+
const result = await fixComponent(viewerUrl2, {
|
|
1620
|
+
component: componentName,
|
|
1621
|
+
variant: variantName,
|
|
1622
|
+
fixType
|
|
1623
|
+
});
|
|
1624
|
+
if (result.error) {
|
|
1625
|
+
return {
|
|
1626
|
+
content: [{
|
|
1627
|
+
type: "text",
|
|
1628
|
+
text: `Fix generation error: ${result.error}`
|
|
1629
|
+
}],
|
|
1630
|
+
isError: true
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
return {
|
|
1634
|
+
content: [{
|
|
1635
|
+
type: "text",
|
|
1636
|
+
text: JSON.stringify({
|
|
1637
|
+
component: componentName,
|
|
1638
|
+
variant: variantName ?? "all",
|
|
1639
|
+
fixType,
|
|
1640
|
+
patches: result.patches,
|
|
1641
|
+
summary: result.summary,
|
|
1642
|
+
patchCount: result.patches.length,
|
|
1643
|
+
nextStep: result.patches.length > 0 ? "Apply patches using your editor or `patch` command, then run fragments_render to confirm fixes." : void 0
|
|
1644
|
+
}, null, 2)
|
|
1645
|
+
}]
|
|
1646
|
+
};
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
return {
|
|
1649
|
+
content: [{
|
|
1650
|
+
type: "text",
|
|
1651
|
+
text: `Failed to generate fixes: ${error instanceof Error ? error.message : "Unknown error"}. Make sure the Fragments dev server is running.`
|
|
1652
|
+
}],
|
|
1653
|
+
isError: true
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
default:
|
|
1658
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1659
|
+
}
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
return {
|
|
1662
|
+
content: [{
|
|
1663
|
+
type: "text",
|
|
1664
|
+
text: JSON.stringify({
|
|
1665
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1666
|
+
})
|
|
1667
|
+
}],
|
|
1668
|
+
isError: true
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
return server;
|
|
1673
|
+
}
|
|
1674
|
+
async function startMcpServer(config) {
|
|
1675
|
+
const server = createMcpServer(config);
|
|
1676
|
+
const transport = new StdioServerTransport();
|
|
1677
|
+
await server.connect(transport);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// src/bin.ts
|
|
1681
|
+
var args = process.argv.slice(2);
|
|
1682
|
+
var projectRoot = process.cwd();
|
|
1683
|
+
var viewerUrl;
|
|
1684
|
+
for (let i = 0; i < args.length; i++) {
|
|
1685
|
+
const arg = args[i];
|
|
1686
|
+
if (arg === "--project-root" || arg === "-p") {
|
|
1687
|
+
projectRoot = args[++i] ?? projectRoot;
|
|
1688
|
+
} else if (arg === "--viewer-url" || arg === "-u") {
|
|
1689
|
+
viewerUrl = args[++i];
|
|
1690
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
1691
|
+
console.log(`
|
|
1692
|
+
Usage: fragments-mcp [options]
|
|
1693
|
+
|
|
1694
|
+
Standalone MCP server for Fragments design system.
|
|
1695
|
+
Provides component discovery with semantic search, no CLI/Playwright needed.
|
|
1696
|
+
|
|
1697
|
+
Options:
|
|
1698
|
+
-p, --project-root <path> Project root directory (default: cwd)
|
|
1699
|
+
-u, --viewer-url <url> Viewer URL for render/fix tools (optional)
|
|
1700
|
+
-h, --help Show this help message
|
|
1701
|
+
|
|
1702
|
+
Setup (Claude Code / Cursor):
|
|
1703
|
+
{
|
|
1704
|
+
"mcpServers": {
|
|
1705
|
+
"fragments": {
|
|
1706
|
+
"command": "npx",
|
|
1707
|
+
"args": ["@fragments-sdk/mcp"]
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
`);
|
|
1712
|
+
process.exit(0);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
startMcpServer({
|
|
1716
|
+
projectRoot,
|
|
1717
|
+
viewerUrl
|
|
1718
|
+
}).catch((error) => {
|
|
1719
|
+
console.error("Failed to start MCP server:", error);
|
|
1720
|
+
process.exit(1);
|
|
1721
|
+
});
|
|
1722
|
+
//# sourceMappingURL=bin.js.map
|