@fragments-sdk/cli 0.15.9 → 0.15.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/mcp-bin.js +779 -132
- package/dist/mcp-bin.js.map +1 -1
- package/package.json +3 -3
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +1 -1
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +1 -1
- package/src/mcp/__tests__/server.integration.test.ts +342 -0
- package/src/mcp/server.ts +924 -138
- package/src/mcp/version.ts +17 -0
package/src/mcp/server.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
type Theme,
|
|
15
15
|
} from '../core/index.js';
|
|
16
16
|
import { buildMcpTools, buildToolNames, CLI_TOOL_EXTENSIONS } from '@fragments-sdk/context/mcp-tools';
|
|
17
|
+
import type { CompiledBlock, CompiledTokenData } from '@fragments-sdk/context/types';
|
|
18
|
+
import { ComponentGraphEngine, deserializeGraph } from '@fragments-sdk/context/graph';
|
|
19
|
+
import type { GraphEdgeType } from '@fragments-sdk/context/graph';
|
|
20
|
+
import type { EngineOptions as GovernEngineOptions, McpGovernInput } from '@fragments-sdk/govern';
|
|
17
21
|
// ../service is lazy-imported to avoid requiring playwright at startup.
|
|
18
22
|
// Visual tools (render, fix) load it on first use.
|
|
19
23
|
type ServiceModule = typeof import('../service/index.js');
|
|
@@ -35,11 +39,12 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
|
35
39
|
import { join, dirname, resolve } from 'node:path';
|
|
36
40
|
import { createRequire } from 'node:module';
|
|
37
41
|
import { projectFields } from './utils.js';
|
|
42
|
+
import { MCP_SERVER_VERSION } from './version.js';
|
|
38
43
|
|
|
39
44
|
/**
|
|
40
45
|
* MCP Tool names & definitions (from shared source of truth)
|
|
41
46
|
*/
|
|
42
|
-
const TOOL_NAMES = buildToolNames(
|
|
47
|
+
const TOOL_NAMES = buildToolNames() as Record<string, string> & {
|
|
43
48
|
discover: string;
|
|
44
49
|
inspect: string;
|
|
45
50
|
blocks: string;
|
|
@@ -47,8 +52,92 @@ const TOOL_NAMES = buildToolNames(BRAND.nameLower) as Record<string, string> & {
|
|
|
47
52
|
implement: string;
|
|
48
53
|
render: string;
|
|
49
54
|
fix: string;
|
|
55
|
+
graph: string;
|
|
56
|
+
a11y: string;
|
|
57
|
+
perf: string;
|
|
58
|
+
generate_ui: string;
|
|
59
|
+
govern: string;
|
|
50
60
|
};
|
|
51
61
|
|
|
62
|
+
const TOOLS = buildMcpTools(undefined, CLI_TOOL_EXTENSIONS) as Tool[];
|
|
63
|
+
|
|
64
|
+
type McpVerbosity = 'compact' | 'standard' | 'full';
|
|
65
|
+
type TokenCategorySummary = { category: string; count: number };
|
|
66
|
+
|
|
67
|
+
const DISCOVER_SYNONYM_MAP: Record<string, string[]> = {
|
|
68
|
+
form: ['input', 'field', 'submit', 'validation'],
|
|
69
|
+
input: ['form', 'field', 'text', 'entry'],
|
|
70
|
+
button: ['action', 'click', 'submit', 'trigger'],
|
|
71
|
+
action: ['button', 'click', 'trigger'],
|
|
72
|
+
submit: ['button', 'form', 'action', 'send'],
|
|
73
|
+
alert: ['notification', 'message', 'warning', 'error', 'feedback'],
|
|
74
|
+
notification: ['alert', 'message', 'toast'],
|
|
75
|
+
feedback: ['form', 'comment', 'review', 'rating'],
|
|
76
|
+
card: ['container', 'panel', 'box', 'content'],
|
|
77
|
+
toggle: ['switch', 'checkbox', 'boolean', 'on/off'],
|
|
78
|
+
switch: ['toggle', 'checkbox', 'boolean'],
|
|
79
|
+
badge: ['tag', 'label', 'status', 'indicator'],
|
|
80
|
+
status: ['badge', 'indicator', 'state'],
|
|
81
|
+
login: ['auth', 'signin', 'authentication', 'form'],
|
|
82
|
+
auth: ['login', 'signin', 'authentication'],
|
|
83
|
+
chat: ['message', 'conversation', 'ai'],
|
|
84
|
+
table: ['data', 'grid', 'list', 'rows'],
|
|
85
|
+
layout: ['stack', 'grid', 'box', 'container', 'page'],
|
|
86
|
+
landing: ['page', 'hero', 'marketing', 'section', 'layout'],
|
|
87
|
+
hero: ['landing', 'marketing', 'banner', 'headline', 'section'],
|
|
88
|
+
marketing: ['landing', 'hero', 'pricing', 'testimonial', 'cta'],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const TOKEN_CATEGORY_ALIASES: Record<string, string[]> = {
|
|
92
|
+
colors: ['color', 'colors', 'accent', 'background', 'foreground', 'semantic', 'theme'],
|
|
93
|
+
spacing: ['spacing', 'space', 'spaces', 'padding', 'margin', 'gap', 'inset'],
|
|
94
|
+
typography: ['typography', 'type', 'font', 'fonts', 'letter', 'line-height'],
|
|
95
|
+
surfaces: ['surface', 'surfaces', 'canvas', 'backgrounds'],
|
|
96
|
+
shadows: ['shadow', 'shadows', 'elevation'],
|
|
97
|
+
radius: ['radius', 'radii', 'corner', 'corners', 'round', 'rounded', 'rounding'],
|
|
98
|
+
borders: ['border', 'borders', 'stroke', 'outline'],
|
|
99
|
+
text: ['text', 'copy', 'content'],
|
|
100
|
+
focus: ['focus', 'ring', 'focus-ring'],
|
|
101
|
+
layout: ['layout', 'container', 'grid', 'breakpoint'],
|
|
102
|
+
code: ['code'],
|
|
103
|
+
'component-sizing': ['component-sizing', 'sizing', 'size', 'sizes'],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const FRIENDLY_TOKEN_CATEGORY_ORDER = [
|
|
107
|
+
'colors',
|
|
108
|
+
'spacing',
|
|
109
|
+
'typography',
|
|
110
|
+
'surfaces',
|
|
111
|
+
'shadows',
|
|
112
|
+
'radius',
|
|
113
|
+
'borders',
|
|
114
|
+
'text',
|
|
115
|
+
'focus',
|
|
116
|
+
'layout',
|
|
117
|
+
'code',
|
|
118
|
+
'component-sizing',
|
|
119
|
+
] as const;
|
|
120
|
+
|
|
121
|
+
const STYLE_QUERY_TERMS = new Set([
|
|
122
|
+
'color',
|
|
123
|
+
'colors',
|
|
124
|
+
'spacing',
|
|
125
|
+
'padding',
|
|
126
|
+
'margin',
|
|
127
|
+
'font',
|
|
128
|
+
'border',
|
|
129
|
+
'radius',
|
|
130
|
+
'shadow',
|
|
131
|
+
'variable',
|
|
132
|
+
'token',
|
|
133
|
+
'css',
|
|
134
|
+
'theme',
|
|
135
|
+
'background',
|
|
136
|
+
'hover',
|
|
137
|
+
'surface',
|
|
138
|
+
'focus',
|
|
139
|
+
]);
|
|
140
|
+
|
|
52
141
|
/**
|
|
53
142
|
* Placeholder patterns to filter out from usage text.
|
|
54
143
|
* These are auto-generated and provide no value to AI agents.
|
|
@@ -69,6 +158,231 @@ function filterPlaceholders(items: string[] | undefined): string[] {
|
|
|
69
158
|
);
|
|
70
159
|
}
|
|
71
160
|
|
|
161
|
+
function parsePositiveLimit(
|
|
162
|
+
value: unknown,
|
|
163
|
+
defaultValue: number | undefined,
|
|
164
|
+
max: number
|
|
165
|
+
): number | undefined {
|
|
166
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
167
|
+
return defaultValue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return Math.min(Math.max(Math.floor(value), 1), max);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveVerbosity(value: unknown, compactAlias = false): McpVerbosity {
|
|
174
|
+
if (value === 'compact' || value === 'standard' || value === 'full') {
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return compactAlias ? 'compact' : 'standard';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeSearchText(value: string | undefined): string {
|
|
182
|
+
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function splitSearchTerms(value: string | undefined): string[] {
|
|
186
|
+
return Array.from(new Set(normalizeSearchText(value).split(/\s+/).filter(Boolean)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function expandSearchTerms(terms: string[]): string[] {
|
|
190
|
+
const expanded = new Set(terms);
|
|
191
|
+
|
|
192
|
+
for (const term of terms) {
|
|
193
|
+
for (const synonym of DISCOVER_SYNONYM_MAP[term] ?? []) {
|
|
194
|
+
expanded.add(synonym);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return Array.from(expanded);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function countTermMatches(haystack: string, terms: string[]): number {
|
|
202
|
+
return terms.reduce((count, term) => count + (haystack.includes(term) ? 1 : 0), 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function truncateCodePreview(code: string, previewLines = 20, threshold = 30): string {
|
|
206
|
+
const lines = code.split('\n');
|
|
207
|
+
if (lines.length <= threshold) {
|
|
208
|
+
return code;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return `${lines.slice(0, previewLines).join('\n')}\n// ... truncated (${lines.length} lines total)`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assignConfidence(score: number): 'high' | 'medium' | 'low' {
|
|
215
|
+
if (score >= 25) return 'high';
|
|
216
|
+
if (score >= 15) return 'medium';
|
|
217
|
+
return 'low';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function canonicalizeTokenCategory(categoryKey: string): string | undefined {
|
|
221
|
+
const normalized = normalizeSearchText(categoryKey);
|
|
222
|
+
for (const canonical of FRIENDLY_TOKEN_CATEGORY_ORDER) {
|
|
223
|
+
if (normalized === canonical) {
|
|
224
|
+
return canonical;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const aliases = TOKEN_CATEGORY_ALIASES[canonical] ?? [];
|
|
228
|
+
if (normalized.includes(canonical) || aliases.some((alias) => normalized.includes(alias))) {
|
|
229
|
+
return canonical;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function resolveTokenCategoryKeys(
|
|
237
|
+
tokenData: CompiledTokenData,
|
|
238
|
+
requestedCategory: string | undefined
|
|
239
|
+
): { keys: string[]; canonical?: string } {
|
|
240
|
+
if (!requestedCategory) {
|
|
241
|
+
return { keys: Object.keys(tokenData.categories) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const normalized = normalizeSearchText(requestedCategory);
|
|
245
|
+
if (!normalized) {
|
|
246
|
+
return { keys: Object.keys(tokenData.categories) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const keys = Object.keys(tokenData.categories);
|
|
250
|
+
const exactRawMatches = keys.filter((key) => normalizeSearchText(key) === normalized);
|
|
251
|
+
if (exactRawMatches.length > 0) {
|
|
252
|
+
return { keys: exactRawMatches, canonical: canonicalizeTokenCategory(exactRawMatches[0]) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const canonical = FRIENDLY_TOKEN_CATEGORY_ORDER.find((candidate) => {
|
|
256
|
+
if (candidate === normalized) return true;
|
|
257
|
+
return (TOKEN_CATEGORY_ALIASES[candidate] ?? []).includes(normalized);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (canonical) {
|
|
261
|
+
const aliases = [canonical, ...(TOKEN_CATEGORY_ALIASES[canonical] ?? [])];
|
|
262
|
+
const aliasMatches = keys.filter((key) => {
|
|
263
|
+
const normalizedKey = normalizeSearchText(key);
|
|
264
|
+
return aliases.some((alias) => normalizedKey.includes(alias));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (aliasMatches.length > 0) {
|
|
268
|
+
return { keys: aliasMatches, canonical };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const partialMatches = keys.filter((key) => normalizeSearchText(key).includes(normalized));
|
|
273
|
+
return { keys: partialMatches, canonical };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function summarizeFriendlyTokenCategories(tokenData: CompiledTokenData): TokenCategorySummary[] {
|
|
277
|
+
const counts = new Map<string, number>();
|
|
278
|
+
|
|
279
|
+
for (const [rawCategory, tokens] of Object.entries(tokenData.categories)) {
|
|
280
|
+
const canonical = canonicalizeTokenCategory(rawCategory) ?? rawCategory;
|
|
281
|
+
counts.set(canonical, (counts.get(canonical) ?? 0) + tokens.length);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const ordered: TokenCategorySummary[] = [];
|
|
285
|
+
for (const category of FRIENDLY_TOKEN_CATEGORY_ORDER) {
|
|
286
|
+
const count = counts.get(category);
|
|
287
|
+
if (count) {
|
|
288
|
+
ordered.push({ category, count });
|
|
289
|
+
counts.delete(category);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const [category, count] of counts) {
|
|
294
|
+
ordered.push({ category, count });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return ordered;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function limitTokensPerCategory<T>(
|
|
301
|
+
categories: Record<string, T[]>,
|
|
302
|
+
limit: number | undefined
|
|
303
|
+
): { categories: Record<string, T[]>; total: number } {
|
|
304
|
+
if (limit === undefined) {
|
|
305
|
+
return {
|
|
306
|
+
categories,
|
|
307
|
+
total: Object.values(categories).reduce((sum, entries) => sum + entries.length, 0),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const limited: Record<string, T[]> = {};
|
|
312
|
+
let total = 0;
|
|
313
|
+
|
|
314
|
+
for (const [category, entries] of Object.entries(categories)) {
|
|
315
|
+
const sliced = entries.slice(0, limit);
|
|
316
|
+
if (sliced.length === 0) continue;
|
|
317
|
+
limited[category] = sliced;
|
|
318
|
+
total += sliced.length;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return { categories: limited, total };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function scoreBlockMatch(
|
|
325
|
+
block: CompiledBlock,
|
|
326
|
+
query: string,
|
|
327
|
+
preferredComponents: string[] = []
|
|
328
|
+
): number {
|
|
329
|
+
const normalizedQuery = normalizeSearchText(query);
|
|
330
|
+
if (!normalizedQuery) {
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const terms = splitSearchTerms(query);
|
|
335
|
+
const expandedTerms = expandSearchTerms(terms);
|
|
336
|
+
const synonymOnlyTerms = expandedTerms.filter((term) => !terms.includes(term));
|
|
337
|
+
|
|
338
|
+
const nameText = normalizeSearchText(block.name);
|
|
339
|
+
const descriptionText = normalizeSearchText(block.description);
|
|
340
|
+
const tagsText = normalizeSearchText((block.tags ?? []).join(' '));
|
|
341
|
+
const componentsText = normalizeSearchText(block.components.join(' '));
|
|
342
|
+
const categoryText = normalizeSearchText(block.category);
|
|
343
|
+
|
|
344
|
+
let score = 0;
|
|
345
|
+
|
|
346
|
+
if (nameText === normalizedQuery) score += 120;
|
|
347
|
+
else if (nameText.includes(normalizedQuery)) score += 90;
|
|
348
|
+
|
|
349
|
+
if (tagsText.includes(normalizedQuery)) score += 70;
|
|
350
|
+
if (descriptionText.includes(normalizedQuery)) score += 55;
|
|
351
|
+
if (categoryText.includes(normalizedQuery)) score += 40;
|
|
352
|
+
if (componentsText.includes(normalizedQuery)) score += 25;
|
|
353
|
+
|
|
354
|
+
score += countTermMatches(nameText, terms) * 30;
|
|
355
|
+
score += countTermMatches(tagsText, terms) * 22;
|
|
356
|
+
score += countTermMatches(descriptionText, terms) * 16;
|
|
357
|
+
score += countTermMatches(categoryText, terms) * 14;
|
|
358
|
+
score += countTermMatches(componentsText, terms) * 10;
|
|
359
|
+
|
|
360
|
+
score += countTermMatches(nameText, synonymOnlyTerms) * 12;
|
|
361
|
+
score += countTermMatches(tagsText, synonymOnlyTerms) * 10;
|
|
362
|
+
score += countTermMatches(descriptionText, synonymOnlyTerms) * 6;
|
|
363
|
+
|
|
364
|
+
const preferredMatches = block.components.filter((component) =>
|
|
365
|
+
preferredComponents.some((preferred) => preferred.toLowerCase() === component.toLowerCase())
|
|
366
|
+
);
|
|
367
|
+
score += preferredMatches.length * 18;
|
|
368
|
+
|
|
369
|
+
return score;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function rankBlocks(
|
|
373
|
+
blocks: CompiledBlock[],
|
|
374
|
+
query: string,
|
|
375
|
+
preferredComponents: string[] = []
|
|
376
|
+
): Array<{ block: CompiledBlock; score: number }> {
|
|
377
|
+
return blocks
|
|
378
|
+
.map((block) => ({
|
|
379
|
+
block,
|
|
380
|
+
score: scoreBlockMatch(block, query, preferredComponents),
|
|
381
|
+
}))
|
|
382
|
+
.filter((entry) => entry.score > 0)
|
|
383
|
+
.sort((a, b) => b.score - a.score || a.block.name.localeCompare(b.block.name));
|
|
384
|
+
}
|
|
385
|
+
|
|
72
386
|
/**
|
|
73
387
|
* Resolve workspace directory globs (e.g. "apps/*") into actual paths.
|
|
74
388
|
*/
|
|
@@ -278,9 +592,6 @@ export interface McpServerConfig {
|
|
|
278
592
|
/** Diff threshold percentage */
|
|
279
593
|
threshold?: number;
|
|
280
594
|
}
|
|
281
|
-
|
|
282
|
-
const TOOLS = buildMcpTools(BRAND.nameLower, CLI_TOOL_EXTENSIONS) as Tool[];
|
|
283
|
-
|
|
284
595
|
/**
|
|
285
596
|
* Create and configure the MCP server
|
|
286
597
|
*/
|
|
@@ -288,7 +599,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
288
599
|
const server = new Server(
|
|
289
600
|
{
|
|
290
601
|
name: `${BRAND.nameLower}-mcp`,
|
|
291
|
-
version:
|
|
602
|
+
version: MCP_SERVER_VERSION,
|
|
292
603
|
},
|
|
293
604
|
{
|
|
294
605
|
capabilities: {
|
|
@@ -482,24 +793,27 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
482
793
|
const data = await loadFragments();
|
|
483
794
|
const useCase = (args?.useCase as string) ?? undefined;
|
|
484
795
|
const componentForAlts = (args?.component as string) ?? undefined;
|
|
485
|
-
const category = (args?.category as string)
|
|
486
|
-
const search = (args?.search as string)
|
|
796
|
+
const category = normalizeSearchText(args?.category as string | undefined) || undefined;
|
|
797
|
+
const search = normalizeSearchText(args?.search as string | undefined) || undefined;
|
|
487
798
|
const status = (args?.status as string) ?? undefined;
|
|
488
799
|
const format = (args?.format as 'markdown' | 'json') ?? 'markdown';
|
|
489
800
|
const compact = (args?.compact as boolean) ?? false;
|
|
490
801
|
const includeCode = (args?.includeCode as boolean) ?? false;
|
|
491
802
|
const includeRelations = (args?.includeRelations as boolean) ?? false;
|
|
803
|
+
const verbosity = resolveVerbosity(args?.verbosity, compact);
|
|
804
|
+
const listLimit = parsePositiveLimit(args?.limit, undefined, 50);
|
|
805
|
+
const suggestLimit = parsePositiveLimit(args?.limit, 10, 25) ?? 10;
|
|
492
806
|
|
|
493
|
-
// --- Context mode:
|
|
494
|
-
if (
|
|
807
|
+
// --- Context mode: explicit format request with no specific query ---
|
|
808
|
+
if (args?.format && !useCase && !componentForAlts && !category && !search && !status) {
|
|
495
809
|
const fragments = Object.values(data.fragments);
|
|
496
810
|
const allBlocks = Object.values(data.blocks ?? data.recipes ?? {});
|
|
497
811
|
|
|
498
812
|
const { content: ctxContent, tokenEstimate } = generateContext(fragments, {
|
|
499
813
|
format,
|
|
500
|
-
compact,
|
|
814
|
+
compact: verbosity === 'compact',
|
|
501
815
|
include: {
|
|
502
|
-
code: includeCode,
|
|
816
|
+
code: includeCode || verbosity === 'full',
|
|
503
817
|
relations: includeRelations,
|
|
504
818
|
},
|
|
505
819
|
}, allBlocks);
|
|
@@ -517,31 +831,9 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
517
831
|
if (useCase) {
|
|
518
832
|
const useCaseLower = useCase.toLowerCase();
|
|
519
833
|
const context = ((args as Record<string, unknown>)?.context as string)?.toLowerCase() ?? '';
|
|
520
|
-
const searchTerms = `${useCaseLower} ${context}
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
'form': ['input', 'field', 'submit', 'validation'],
|
|
524
|
-
'input': ['form', 'field', 'text', 'entry'],
|
|
525
|
-
'button': ['action', 'click', 'submit', 'trigger'],
|
|
526
|
-
'action': ['button', 'click', 'trigger'],
|
|
527
|
-
'alert': ['notification', 'message', 'warning', 'error', 'feedback'],
|
|
528
|
-
'notification': ['alert', 'message', 'toast'],
|
|
529
|
-
'card': ['container', 'panel', 'box', 'content'],
|
|
530
|
-
'toggle': ['switch', 'checkbox', 'boolean', 'on/off'],
|
|
531
|
-
'switch': ['toggle', 'checkbox', 'boolean'],
|
|
532
|
-
'badge': ['tag', 'label', 'status', 'indicator'],
|
|
533
|
-
'status': ['badge', 'indicator', 'state'],
|
|
534
|
-
'login': ['auth', 'signin', 'authentication', 'form'],
|
|
535
|
-
'auth': ['login', 'signin', 'authentication'],
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
const expandedTerms = new Set(searchTerms);
|
|
539
|
-
searchTerms.forEach(term => {
|
|
540
|
-
const synonyms = synonymMap[term];
|
|
541
|
-
if (synonyms) {
|
|
542
|
-
synonyms.forEach(syn => expandedTerms.add(syn));
|
|
543
|
-
}
|
|
544
|
-
});
|
|
834
|
+
const searchTerms = splitSearchTerms(`${useCaseLower} ${context}`);
|
|
835
|
+
const expandedTerms = expandSearchTerms(searchTerms);
|
|
836
|
+
const synonymOnlyTerms = expandedTerms.filter((term) => !searchTerms.includes(term));
|
|
545
837
|
|
|
546
838
|
const scored = Object.values(data.fragments).map((s) => {
|
|
547
839
|
let score = 0;
|
|
@@ -551,7 +843,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
551
843
|
if (searchTerms.some((term) => nameLower.includes(term))) {
|
|
552
844
|
score += 15;
|
|
553
845
|
reasons.push(`Name matches search`);
|
|
554
|
-
} else if (
|
|
846
|
+
} else if (expandedTerms.some((term) => nameLower.includes(term))) {
|
|
555
847
|
score += 8;
|
|
556
848
|
reasons.push(`Name matches related term`);
|
|
557
849
|
}
|
|
@@ -579,9 +871,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
579
871
|
reasons.push(`Use cases match: "${whenMatches.join(', ')}"`);
|
|
580
872
|
}
|
|
581
873
|
|
|
582
|
-
const expandedWhenMatches =
|
|
583
|
-
(term) => !searchTerms.includes(term) && whenUsed.includes(term)
|
|
584
|
-
);
|
|
874
|
+
const expandedWhenMatches = synonymOnlyTerms.filter((term) => whenUsed.includes(term));
|
|
585
875
|
if (expandedWhenMatches.length > 0) {
|
|
586
876
|
score += expandedWhenMatches.length * 5;
|
|
587
877
|
reasons.push(`Related use cases: "${expandedWhenMatches.join(', ')}"`);
|
|
@@ -648,7 +938,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
648
938
|
if (count < 2 || suggestions.length < 3) {
|
|
649
939
|
suggestions.push(item);
|
|
650
940
|
categoryCount[cat] = count + 1;
|
|
651
|
-
if (suggestions.length >=
|
|
941
|
+
if (suggestions.length >= suggestLimit) break;
|
|
652
942
|
}
|
|
653
943
|
}
|
|
654
944
|
|
|
@@ -657,8 +947,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
657
947
|
: undefined;
|
|
658
948
|
|
|
659
949
|
// Detect if query is about styling/tokens rather than components
|
|
660
|
-
const
|
|
661
|
-
const isStyleQuery = STYLE_KEYWORDS.some((kw) => useCaseLower.includes(kw));
|
|
950
|
+
const isStyleQuery = splitSearchTerms(useCaseLower).some((term) => STYLE_QUERY_TERMS.has(term));
|
|
662
951
|
|
|
663
952
|
// Determine no-match quality
|
|
664
953
|
const noMatch = suggestions.length === 0;
|
|
@@ -669,7 +958,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
669
958
|
if (noMatch) {
|
|
670
959
|
recommendation = isStyleQuery
|
|
671
960
|
? `No matching components found. Your query seems styling-related — try ${TOOL_NAMES.tokens} to find CSS custom properties.`
|
|
672
|
-
:
|
|
961
|
+
: `No matching components found. Try different keywords or browse all components with ${TOOL_NAMES.discover}.`;
|
|
673
962
|
nextStep = isStyleQuery
|
|
674
963
|
? `Use ${TOOL_NAMES.tokens}(search: "${searchTerms[0]}") to find design tokens.`
|
|
675
964
|
: undefined;
|
|
@@ -681,13 +970,23 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
681
970
|
nextStep = `Use ${TOOL_NAMES.inspect}("${suggestions[0].component}") for full details.`;
|
|
682
971
|
}
|
|
683
972
|
|
|
973
|
+
const suggestionResults = suggestions.map(({ score, ...rest }) =>
|
|
974
|
+
verbosity === 'full' ? { ...rest, score } : rest
|
|
975
|
+
);
|
|
976
|
+
|
|
684
977
|
return {
|
|
685
978
|
content: [{
|
|
686
979
|
type: 'text' as const,
|
|
687
980
|
text: JSON.stringify({
|
|
688
981
|
useCase,
|
|
689
982
|
context: context || undefined,
|
|
690
|
-
suggestions:
|
|
983
|
+
suggestions: verbosity === 'compact'
|
|
984
|
+
? suggestionResults.map((suggestion) => ({
|
|
985
|
+
component: suggestion.component,
|
|
986
|
+
description: suggestion.description,
|
|
987
|
+
confidence: suggestion.confidence,
|
|
988
|
+
}))
|
|
989
|
+
: suggestionResults,
|
|
691
990
|
noMatch,
|
|
692
991
|
weakMatch,
|
|
693
992
|
recommendation,
|
|
@@ -705,7 +1004,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
705
1004
|
);
|
|
706
1005
|
|
|
707
1006
|
if (!fragment) {
|
|
708
|
-
throw new Error(`Component "${componentForAlts}" not found. Use
|
|
1007
|
+
throw new Error(`Component "${componentForAlts}" not found. Use ${TOOL_NAMES.discover} to see available components.`);
|
|
709
1008
|
}
|
|
710
1009
|
|
|
711
1010
|
const relations = fragment.relations ?? [];
|
|
@@ -755,7 +1054,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
755
1054
|
// --- Default: list mode ---
|
|
756
1055
|
const fragments = Object.values(data.fragments)
|
|
757
1056
|
.filter((s) => {
|
|
758
|
-
if (category && s.meta.category !== category) return false;
|
|
1057
|
+
if (category && normalizeSearchText(s.meta.category) !== category) return false;
|
|
759
1058
|
if (status && (s.meta.status ?? 'stable') !== status) return false;
|
|
760
1059
|
if (search) {
|
|
761
1060
|
const nameMatch = s.meta.name.toLowerCase().includes(search);
|
|
@@ -764,27 +1063,54 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
764
1063
|
if (!nameMatch && !descMatch && !tagMatch) return false;
|
|
765
1064
|
}
|
|
766
1065
|
return true;
|
|
767
|
-
})
|
|
768
|
-
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
const total = fragments.length;
|
|
1069
|
+
const limitedFragments = listLimit === undefined ? fragments : fragments.slice(0, listLimit);
|
|
1070
|
+
const formattedFragments = limitedFragments.map((s) => {
|
|
1071
|
+
const base = {
|
|
769
1072
|
name: s.meta.name,
|
|
770
1073
|
category: s.meta.category,
|
|
771
1074
|
description: s.meta.description,
|
|
772
1075
|
status: s.meta.status ?? 'stable',
|
|
773
1076
|
variantCount: s.variants.length,
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
if (verbosity === 'compact') {
|
|
1080
|
+
return base;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (verbosity === 'full') {
|
|
1084
|
+
return {
|
|
1085
|
+
...base,
|
|
1086
|
+
tags: s.meta.tags ?? [],
|
|
1087
|
+
usage: {
|
|
1088
|
+
when: filterPlaceholders(s.usage?.when).slice(0, 3),
|
|
1089
|
+
whenNot: filterPlaceholders(s.usage?.whenNot).slice(0, 2),
|
|
1090
|
+
},
|
|
1091
|
+
relations: s.relations ?? [],
|
|
1092
|
+
codeExample: s.variants[0]?.code,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return {
|
|
1097
|
+
...base,
|
|
774
1098
|
tags: s.meta.tags ?? [],
|
|
775
|
-
}
|
|
1099
|
+
};
|
|
1100
|
+
});
|
|
776
1101
|
|
|
777
1102
|
return {
|
|
778
1103
|
content: [{
|
|
779
1104
|
type: 'text' as const,
|
|
780
1105
|
text: JSON.stringify({
|
|
781
|
-
total
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1106
|
+
total,
|
|
1107
|
+
returned: formattedFragments.length,
|
|
1108
|
+
fragments: formattedFragments,
|
|
1109
|
+
categories: [...new Set(fragments.map((s) => s.meta.category))],
|
|
1110
|
+
hint: total === 0
|
|
785
1111
|
? 'No components found. Try broader search terms or check available categories.'
|
|
786
|
-
:
|
|
787
|
-
?
|
|
1112
|
+
: total > 5
|
|
1113
|
+
? `Use ${TOOL_NAMES.discover} with useCase for recommendations, or ${TOOL_NAMES.inspect} for details on a specific component.`
|
|
788
1114
|
: undefined,
|
|
789
1115
|
}, null, 2),
|
|
790
1116
|
}],
|
|
@@ -811,7 +1137,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
811
1137
|
);
|
|
812
1138
|
|
|
813
1139
|
if (!fragment) {
|
|
814
|
-
throw new Error(`Component "${componentName}" not found. Use
|
|
1140
|
+
throw new Error(`Component "${componentName}" not found. Use ${TOOL_NAMES.discover} to see available components.`);
|
|
815
1141
|
}
|
|
816
1142
|
|
|
817
1143
|
// Build the full inspect result combining get + guidelines + example
|
|
@@ -937,9 +1263,11 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
937
1263
|
case TOOL_NAMES.blocks: {
|
|
938
1264
|
const data = await loadFragments();
|
|
939
1265
|
const blockName = args?.name as string | undefined;
|
|
940
|
-
const search = (args?.search as string)
|
|
1266
|
+
const search = (args?.search as string) ?? undefined;
|
|
941
1267
|
const component = (args?.component as string)?.toLowerCase() ?? undefined;
|
|
942
|
-
const category = (args?.category as string)
|
|
1268
|
+
const category = normalizeSearchText(args?.category as string | undefined) || undefined;
|
|
1269
|
+
const blocksLimit = parsePositiveLimit(args?.limit, undefined, 50);
|
|
1270
|
+
const verbosity = resolveVerbosity(args?.verbosity);
|
|
943
1271
|
|
|
944
1272
|
const allBlocks = Object.values(data.blocks ?? data.recipes ?? {});
|
|
945
1273
|
|
|
@@ -959,22 +1287,11 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
959
1287
|
let filtered = allBlocks;
|
|
960
1288
|
|
|
961
1289
|
if (blockName) {
|
|
962
|
-
filtered = filtered.filter(
|
|
963
|
-
b => b.name.toLowerCase() === blockName.toLowerCase()
|
|
964
|
-
);
|
|
1290
|
+
filtered = filtered.filter((b) => b.name.toLowerCase() === blockName.toLowerCase());
|
|
965
1291
|
}
|
|
966
1292
|
|
|
967
1293
|
if (search) {
|
|
968
|
-
filtered = filtered.
|
|
969
|
-
const haystack = [
|
|
970
|
-
b.name,
|
|
971
|
-
b.description,
|
|
972
|
-
...(b.tags ?? []),
|
|
973
|
-
...b.components,
|
|
974
|
-
b.category,
|
|
975
|
-
].join(' ').toLowerCase();
|
|
976
|
-
return haystack.includes(search);
|
|
977
|
-
});
|
|
1294
|
+
filtered = rankBlocks(filtered, search).map(({ block }) => block);
|
|
978
1295
|
}
|
|
979
1296
|
|
|
980
1297
|
if (component) {
|
|
@@ -985,16 +1302,48 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
985
1302
|
|
|
986
1303
|
if (category) {
|
|
987
1304
|
filtered = filtered.filter(b =>
|
|
988
|
-
b.category
|
|
1305
|
+
normalizeSearchText(b.category) === category
|
|
989
1306
|
);
|
|
990
1307
|
}
|
|
991
1308
|
|
|
1309
|
+
if (!search) {
|
|
1310
|
+
filtered = [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const total = filtered.length;
|
|
1314
|
+
if (blocksLimit !== undefined) {
|
|
1315
|
+
filtered = filtered.slice(0, blocksLimit);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const blocks = filtered.map((block) => {
|
|
1319
|
+
const base = {
|
|
1320
|
+
name: block.name,
|
|
1321
|
+
description: block.description,
|
|
1322
|
+
category: block.category,
|
|
1323
|
+
components: block.components,
|
|
1324
|
+
tags: block.tags,
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
if (verbosity === 'compact') {
|
|
1328
|
+
return base;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
return {
|
|
1332
|
+
...base,
|
|
1333
|
+
code: verbosity === 'full' ? block.code : truncateCodePreview(block.code),
|
|
1334
|
+
};
|
|
1335
|
+
});
|
|
1336
|
+
|
|
992
1337
|
return {
|
|
993
1338
|
content: [{
|
|
994
1339
|
type: 'text' as const,
|
|
995
1340
|
text: JSON.stringify({
|
|
996
|
-
total
|
|
997
|
-
|
|
1341
|
+
total,
|
|
1342
|
+
returned: blocks.length,
|
|
1343
|
+
blocks,
|
|
1344
|
+
...(total === 0 && {
|
|
1345
|
+
hint: 'No blocks matching your query. Try broader search terms.',
|
|
1346
|
+
}),
|
|
998
1347
|
}, null, 2),
|
|
999
1348
|
}],
|
|
1000
1349
|
};
|
|
@@ -1005,8 +1354,9 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1005
1354
|
// ================================================================
|
|
1006
1355
|
case TOOL_NAMES.tokens: {
|
|
1007
1356
|
const data = await loadFragments();
|
|
1008
|
-
const category = (args?.category as string)
|
|
1009
|
-
const search = (args?.search as string)
|
|
1357
|
+
const category = (args?.category as string) ?? undefined;
|
|
1358
|
+
const search = normalizeSearchText(args?.search as string | undefined) || undefined;
|
|
1359
|
+
const tokensLimit = parsePositiveLimit(args?.limit, search ? 25 : undefined, 100);
|
|
1010
1360
|
|
|
1011
1361
|
const tokenData = data.tokens;
|
|
1012
1362
|
|
|
@@ -1025,35 +1375,43 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1025
1375
|
|
|
1026
1376
|
// Filter by category and/or search
|
|
1027
1377
|
const filteredCategories: Record<string, Array<{ name: string; description?: string }>> = {};
|
|
1028
|
-
|
|
1378
|
+
const resolvedCategory = resolveTokenCategoryKeys(tokenData, category);
|
|
1379
|
+
const searchCategoryKeys = search ? resolveTokenCategoryKeys(tokenData, search).keys : [];
|
|
1029
1380
|
|
|
1030
1381
|
for (const [cat, tokens] of Object.entries(tokenData.categories)) {
|
|
1031
|
-
|
|
1032
|
-
if (category && cat !== category) continue;
|
|
1382
|
+
if (category && !resolvedCategory.keys.includes(cat)) continue;
|
|
1033
1383
|
|
|
1034
|
-
// Filter by search term within this category
|
|
1035
1384
|
let filtered = tokens;
|
|
1036
1385
|
if (search) {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1386
|
+
const normalizedCategory = normalizeSearchText(cat);
|
|
1387
|
+
const searchMatchesCategory = searchCategoryKeys.includes(cat);
|
|
1388
|
+
if (!searchMatchesCategory) {
|
|
1389
|
+
filtered = tokens.filter(
|
|
1390
|
+
(token) =>
|
|
1391
|
+
token.name.toLowerCase().includes(search) ||
|
|
1392
|
+
(token.description && token.description.toLowerCase().includes(search)) ||
|
|
1393
|
+
normalizedCategory.includes(search)
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1041
1396
|
}
|
|
1042
1397
|
|
|
1043
1398
|
if (filtered.length > 0) {
|
|
1044
1399
|
filteredCategories[cat] = filtered;
|
|
1045
|
-
filteredTotal += filtered.length;
|
|
1046
1400
|
}
|
|
1047
1401
|
}
|
|
1048
1402
|
|
|
1403
|
+
const limited = limitTokensPerCategory(filteredCategories, tokensLimit);
|
|
1404
|
+
const filteredTotal = limited.total;
|
|
1405
|
+
const friendlyCategories = summarizeFriendlyTokenCategories(tokenData);
|
|
1406
|
+
const availableCategoryNames = friendlyCategories.map((entry) => entry.category);
|
|
1407
|
+
|
|
1049
1408
|
// Build usage hint based on context
|
|
1050
1409
|
let hint: string | undefined;
|
|
1051
1410
|
if (filteredTotal === 0) {
|
|
1052
|
-
const availableCategories = Object.keys(tokenData.categories);
|
|
1053
1411
|
hint = search
|
|
1054
|
-
? `No tokens matching "${search}". Try: ${
|
|
1412
|
+
? `No tokens matching "${search}". Try categories like: ${availableCategoryNames.join(', ')}`
|
|
1055
1413
|
: category
|
|
1056
|
-
? `Category "${category}" not found.
|
|
1414
|
+
? `Category "${category}" not found. Try categories like: ${availableCategoryNames.join(', ')}`
|
|
1057
1415
|
: undefined;
|
|
1058
1416
|
} else if (!category && !search) {
|
|
1059
1417
|
hint = `Use var(--token-name) in your CSS/styles. Filter by category or search to narrow results.`;
|
|
@@ -1066,12 +1424,10 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1066
1424
|
prefix: tokenData.prefix,
|
|
1067
1425
|
total: filteredTotal,
|
|
1068
1426
|
totalAvailable: tokenData.total,
|
|
1069
|
-
categories:
|
|
1427
|
+
categories: limited.categories,
|
|
1070
1428
|
...(hint && { hint }),
|
|
1071
1429
|
...((!category && !search) && {
|
|
1072
|
-
availableCategories:
|
|
1073
|
-
([cat, tokens]) => ({ category: cat, count: tokens.length })
|
|
1074
|
-
),
|
|
1430
|
+
availableCategories: friendlyCategories,
|
|
1075
1431
|
}),
|
|
1076
1432
|
}, null, 2),
|
|
1077
1433
|
}],
|
|
@@ -1089,41 +1445,24 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1089
1445
|
}
|
|
1090
1446
|
|
|
1091
1447
|
const useCaseLower = useCase.toLowerCase();
|
|
1092
|
-
const searchTerms = useCaseLower
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
const
|
|
1096
|
-
|
|
1097
|
-
'input': ['form', 'field', 'text', 'entry'],
|
|
1098
|
-
'button': ['action', 'click', 'submit', 'trigger'],
|
|
1099
|
-
'alert': ['notification', 'message', 'warning', 'error', 'feedback'],
|
|
1100
|
-
'notification': ['alert', 'message', 'toast'],
|
|
1101
|
-
'card': ['container', 'panel', 'box', 'content'],
|
|
1102
|
-
'toggle': ['switch', 'checkbox', 'boolean'],
|
|
1103
|
-
'badge': ['tag', 'label', 'status', 'indicator'],
|
|
1104
|
-
'login': ['auth', 'signin', 'authentication', 'form'],
|
|
1105
|
-
'chat': ['message', 'conversation', 'ai'],
|
|
1106
|
-
'table': ['data', 'grid', 'list', 'rows'],
|
|
1107
|
-
};
|
|
1108
|
-
|
|
1109
|
-
const expandedTerms = new Set(searchTerms);
|
|
1110
|
-
searchTerms.forEach((term) => {
|
|
1111
|
-
const synonyms = synonymMap[term];
|
|
1112
|
-
if (synonyms) synonyms.forEach((syn) => expandedTerms.add(syn));
|
|
1113
|
-
});
|
|
1448
|
+
const searchTerms = splitSearchTerms(useCaseLower);
|
|
1449
|
+
const expandedTerms = expandSearchTerms(searchTerms);
|
|
1450
|
+
const synonymOnlyTerms = expandedTerms.filter((term) => !searchTerms.includes(term));
|
|
1451
|
+
const verbosity = resolveVerbosity(args?.verbosity);
|
|
1452
|
+
const componentLimit = parsePositiveLimit(args?.limit, 5, 15) ?? 5;
|
|
1114
1453
|
|
|
1115
1454
|
const scored = Object.values(data.fragments).map((s) => {
|
|
1116
1455
|
let score = 0;
|
|
1117
1456
|
const nameLower = s.meta.name.toLowerCase();
|
|
1118
1457
|
if (searchTerms.some((t) => nameLower.includes(t))) score += 15;
|
|
1119
|
-
else if (
|
|
1458
|
+
else if (expandedTerms.some((t) => nameLower.includes(t))) score += 8;
|
|
1120
1459
|
const desc = s.meta.description?.toLowerCase() ?? '';
|
|
1121
1460
|
score += searchTerms.filter((t) => desc.includes(t)).length * 6;
|
|
1122
1461
|
const tags = s.meta.tags?.map((t) => t.toLowerCase()) ?? [];
|
|
1123
1462
|
score += searchTerms.filter((t) => tags.some((tag) => tag.includes(t))).length * 4;
|
|
1124
1463
|
const whenUsed = s.usage?.when?.join(' ').toLowerCase() ?? '';
|
|
1125
1464
|
score += searchTerms.filter((t) => whenUsed.includes(t)).length * 10;
|
|
1126
|
-
score +=
|
|
1465
|
+
score += synonymOnlyTerms.filter((t) => whenUsed.includes(t)).length * 5;
|
|
1127
1466
|
if (s.meta.category && searchTerms.some((t) => s.meta.category!.toLowerCase().includes(t))) score += 8;
|
|
1128
1467
|
if (s.meta.status === 'stable') score += 5;
|
|
1129
1468
|
if (s.meta.status === 'deprecated') score -= 25;
|
|
@@ -1133,24 +1472,38 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1133
1472
|
const topMatches = scored
|
|
1134
1473
|
.filter((s) => s.score >= 8)
|
|
1135
1474
|
.sort((a, b) => b.score - a.score)
|
|
1136
|
-
.slice(0,
|
|
1475
|
+
.slice(0, componentLimit);
|
|
1137
1476
|
|
|
1138
1477
|
// 2. Build component details for top matches
|
|
1139
1478
|
const components = await Promise.all(
|
|
1140
1479
|
topMatches.map(async ({ fragment: s, score }) => {
|
|
1141
1480
|
const pkgName = await getPackageName(s.meta.name);
|
|
1142
|
-
const
|
|
1481
|
+
const confidence = assignConfidence(score);
|
|
1482
|
+
|
|
1483
|
+
if (verbosity === 'compact') {
|
|
1484
|
+
return {
|
|
1485
|
+
name: s.meta.name,
|
|
1486
|
+
description: s.meta.description,
|
|
1487
|
+
confidence,
|
|
1488
|
+
import: `import { ${s.meta.name} } from '${pkgName}';`,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const exampleLimit = verbosity === 'full' ? s.variants.length : 2;
|
|
1493
|
+
const propsLimit = verbosity === 'full' ? Object.keys(s.props ?? {}).length : 5;
|
|
1494
|
+
const examples = s.variants.slice(0, exampleLimit).map((v) => ({
|
|
1143
1495
|
variant: v.name,
|
|
1144
1496
|
code: v.code ?? `<${s.meta.name} />`,
|
|
1145
1497
|
}));
|
|
1146
|
-
const propsSummary = Object.entries(s.props ?? {}).slice(0,
|
|
1498
|
+
const propsSummary = Object.entries(s.props ?? {}).slice(0, propsLimit).map(
|
|
1147
1499
|
([name, p]) => `${name}${p.required ? ' (required)' : ''}: ${p.type}${p.values ? ` = ${p.values.join('|')}` : ''}`
|
|
1148
1500
|
);
|
|
1501
|
+
|
|
1149
1502
|
return {
|
|
1150
1503
|
name: s.meta.name,
|
|
1151
1504
|
category: s.meta.category,
|
|
1152
1505
|
description: s.meta.description,
|
|
1153
|
-
confidence
|
|
1506
|
+
confidence,
|
|
1154
1507
|
import: `import { ${s.meta.name} } from '${pkgName}';`,
|
|
1155
1508
|
props: propsSummary,
|
|
1156
1509
|
examples,
|
|
@@ -1162,27 +1515,46 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1162
1515
|
|
|
1163
1516
|
// 3. Find relevant blocks
|
|
1164
1517
|
const allBlocks = Object.values(data.blocks ?? data.recipes ?? {});
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
const haystack = [b.name, b.description, ...(b.tags ?? []), ...b.components, b.category].join(' ').toLowerCase();
|
|
1168
|
-
return searchTerms.some((t) => haystack.includes(t)) ||
|
|
1169
|
-
topMatches.some(({ fragment }) => b.components.some((c) => c.toLowerCase() === fragment.meta.name.toLowerCase()));
|
|
1170
|
-
})
|
|
1518
|
+
const preferredComponents = topMatches.map(({ fragment }) => fragment.meta.name);
|
|
1519
|
+
const matchingBlocks = rankBlocks(allBlocks, useCase, preferredComponents)
|
|
1171
1520
|
.slice(0, 2)
|
|
1172
|
-
.map((
|
|
1521
|
+
.map(({ block }) => {
|
|
1522
|
+
const base = {
|
|
1523
|
+
name: block.name,
|
|
1524
|
+
description: block.description,
|
|
1525
|
+
components: block.components,
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
if (verbosity === 'compact') {
|
|
1529
|
+
return base;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
return {
|
|
1533
|
+
...base,
|
|
1534
|
+
code: verbosity === 'full' ? block.code : truncateCodePreview(block.code),
|
|
1535
|
+
};
|
|
1536
|
+
});
|
|
1173
1537
|
|
|
1174
1538
|
// 4. Find relevant tokens
|
|
1175
1539
|
const tokenData = data.tokens;
|
|
1176
1540
|
let relevantTokens: Record<string, string[]> | undefined;
|
|
1177
1541
|
if (tokenData) {
|
|
1178
|
-
const
|
|
1179
|
-
|
|
1180
|
-
|
|
1542
|
+
const styleCategories = Array.from(new Set(searchTerms.flatMap((term) => {
|
|
1543
|
+
return FRIENDLY_TOKEN_CATEGORY_ORDER.filter((categoryName) => {
|
|
1544
|
+
if (categoryName === term) return true;
|
|
1545
|
+
return (TOKEN_CATEGORY_ALIASES[categoryName] ?? []).includes(term);
|
|
1546
|
+
});
|
|
1547
|
+
})));
|
|
1548
|
+
|
|
1549
|
+
if (styleCategories.length > 0) {
|
|
1181
1550
|
relevantTokens = {};
|
|
1182
|
-
for (const
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1551
|
+
for (const categoryName of styleCategories) {
|
|
1552
|
+
const matchingCategoryKeys = resolveTokenCategoryKeys(tokenData, categoryName).keys;
|
|
1553
|
+
for (const key of matchingCategoryKeys) {
|
|
1554
|
+
const tokens = tokenData.categories[key];
|
|
1555
|
+
if (tokens && tokens.length > 0) {
|
|
1556
|
+
relevantTokens[key] = tokens.slice(0, 5).map((token) => token.name);
|
|
1557
|
+
}
|
|
1186
1558
|
}
|
|
1187
1559
|
}
|
|
1188
1560
|
if (Object.keys(relevantTokens).length === 0) relevantTokens = undefined;
|
|
@@ -1196,7 +1568,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1196
1568
|
useCase,
|
|
1197
1569
|
components,
|
|
1198
1570
|
blocks: matchingBlocks.length > 0 ? matchingBlocks : undefined,
|
|
1199
|
-
tokens: relevantTokens,
|
|
1571
|
+
tokens: verbosity === 'compact' ? undefined : relevantTokens,
|
|
1200
1572
|
noMatch: components.length === 0,
|
|
1201
1573
|
summary: components.length > 0
|
|
1202
1574
|
? `Found ${components.length} component(s) for "${useCase}". ${matchingBlocks.length > 0 ? `Plus ${matchingBlocks.length} ready-to-use block(s).` : ''}`
|
|
@@ -1474,7 +1846,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1474
1846
|
);
|
|
1475
1847
|
|
|
1476
1848
|
if (!fragment) {
|
|
1477
|
-
throw new Error(`Component "${componentName}" not found. Use
|
|
1849
|
+
throw new Error(`Component "${componentName}" not found. Use ${TOOL_NAMES.discover} to see available components.`);
|
|
1478
1850
|
}
|
|
1479
1851
|
|
|
1480
1852
|
const baseUrl = config.viewerUrl ?? 'http://localhost:6006';
|
|
@@ -1520,7 +1892,7 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1520
1892
|
summary: result.summary,
|
|
1521
1893
|
patchCount: result.patches.length,
|
|
1522
1894
|
nextStep: result.patches.length > 0
|
|
1523
|
-
?
|
|
1895
|
+
? `Apply patches using your editor or \`patch\` command, then run ${TOOL_NAMES.render} with baseline:true to confirm fixes.`
|
|
1524
1896
|
: undefined,
|
|
1525
1897
|
}, null, 2),
|
|
1526
1898
|
}],
|
|
@@ -1536,6 +1908,420 @@ export function createMcpServer(config: McpServerConfig): Server {
|
|
|
1536
1908
|
}
|
|
1537
1909
|
}
|
|
1538
1910
|
|
|
1911
|
+
// ================================================================
|
|
1912
|
+
// GRAPH — query component relationship graph
|
|
1913
|
+
// ================================================================
|
|
1914
|
+
case TOOL_NAMES.graph: {
|
|
1915
|
+
const data = await loadFragments();
|
|
1916
|
+
const mode = (args?.mode as string) ?? 'health';
|
|
1917
|
+
const componentName = args?.component as string | undefined;
|
|
1918
|
+
const target = args?.target as string | undefined;
|
|
1919
|
+
const edgeTypes = args?.edgeTypes as GraphEdgeType[] | undefined;
|
|
1920
|
+
const maxDepth = (args?.maxDepth as number | undefined) ?? 3;
|
|
1921
|
+
|
|
1922
|
+
if (!data.graph) {
|
|
1923
|
+
return {
|
|
1924
|
+
content: [{
|
|
1925
|
+
type: 'text' as const,
|
|
1926
|
+
text: JSON.stringify({
|
|
1927
|
+
error: 'No graph data available. Run `fragments build` to generate the component graph.',
|
|
1928
|
+
hint: 'The graph is built automatically during `fragments build` and embedded in fragments.json.',
|
|
1929
|
+
}),
|
|
1930
|
+
}],
|
|
1931
|
+
isError: true,
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
const graph = deserializeGraph(data.graph);
|
|
1936
|
+
const blocks = data.blocks
|
|
1937
|
+
? Object.fromEntries(
|
|
1938
|
+
Object.entries(data.blocks).map(([key, value]) => [key, { components: value.components }])
|
|
1939
|
+
)
|
|
1940
|
+
: undefined;
|
|
1941
|
+
const engine = new ComponentGraphEngine(graph, blocks);
|
|
1942
|
+
|
|
1943
|
+
const requireComponent = (modeName: string): string | undefined => {
|
|
1944
|
+
if (!componentName) {
|
|
1945
|
+
return `component is required for ${modeName} mode`;
|
|
1946
|
+
}
|
|
1947
|
+
if (!engine.hasNode(componentName)) {
|
|
1948
|
+
return `Component "${componentName}" not found in graph.`;
|
|
1949
|
+
}
|
|
1950
|
+
return undefined;
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
switch (mode) {
|
|
1954
|
+
case 'health': {
|
|
1955
|
+
const health = engine.getHealth();
|
|
1956
|
+
return {
|
|
1957
|
+
content: [{
|
|
1958
|
+
type: 'text' as const,
|
|
1959
|
+
text: JSON.stringify({
|
|
1960
|
+
mode,
|
|
1961
|
+
...health,
|
|
1962
|
+
summary: `${health.nodeCount} components, ${health.edgeCount} edges, ${health.connectedComponents.length} island(s), ${health.orphans.length} orphan(s), ${health.compositionCoverage}% in blocks`,
|
|
1963
|
+
}, null, 2),
|
|
1964
|
+
}],
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
case 'dependencies': {
|
|
1969
|
+
const error = requireComponent(mode);
|
|
1970
|
+
if (error) throw new Error(error);
|
|
1971
|
+
const dependencies = engine.dependencies(componentName!, edgeTypes);
|
|
1972
|
+
return {
|
|
1973
|
+
content: [{
|
|
1974
|
+
type: 'text' as const,
|
|
1975
|
+
text: JSON.stringify({
|
|
1976
|
+
mode,
|
|
1977
|
+
component: componentName,
|
|
1978
|
+
count: dependencies.length,
|
|
1979
|
+
dependencies: dependencies.map((edge) => ({
|
|
1980
|
+
component: edge.target,
|
|
1981
|
+
type: edge.type,
|
|
1982
|
+
weight: edge.weight,
|
|
1983
|
+
note: edge.note,
|
|
1984
|
+
provenance: edge.provenance,
|
|
1985
|
+
})),
|
|
1986
|
+
}, null, 2),
|
|
1987
|
+
}],
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
case 'dependents': {
|
|
1992
|
+
const error = requireComponent(mode);
|
|
1993
|
+
if (error) throw new Error(error);
|
|
1994
|
+
const dependents = engine.dependents(componentName!, edgeTypes);
|
|
1995
|
+
return {
|
|
1996
|
+
content: [{
|
|
1997
|
+
type: 'text' as const,
|
|
1998
|
+
text: JSON.stringify({
|
|
1999
|
+
mode,
|
|
2000
|
+
component: componentName,
|
|
2001
|
+
count: dependents.length,
|
|
2002
|
+
dependents: dependents.map((edge) => ({
|
|
2003
|
+
component: edge.source,
|
|
2004
|
+
type: edge.type,
|
|
2005
|
+
weight: edge.weight,
|
|
2006
|
+
note: edge.note,
|
|
2007
|
+
provenance: edge.provenance,
|
|
2008
|
+
})),
|
|
2009
|
+
}, null, 2),
|
|
2010
|
+
}],
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
case 'impact': {
|
|
2015
|
+
const error = requireComponent(mode);
|
|
2016
|
+
if (error) throw new Error(error);
|
|
2017
|
+
const impact = engine.impact(componentName!, maxDepth);
|
|
2018
|
+
return {
|
|
2019
|
+
content: [{
|
|
2020
|
+
type: 'text' as const,
|
|
2021
|
+
text: JSON.stringify({
|
|
2022
|
+
mode,
|
|
2023
|
+
...impact,
|
|
2024
|
+
summary: `Changing ${componentName} affects ${impact.totalAffected} component(s) and ${impact.affectedBlocks.length} block(s)`,
|
|
2025
|
+
}, null, 2),
|
|
2026
|
+
}],
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
case 'path': {
|
|
2031
|
+
if (!componentName || !target) {
|
|
2032
|
+
throw new Error('component and target are required for path mode');
|
|
2033
|
+
}
|
|
2034
|
+
const path = engine.path(componentName, target);
|
|
2035
|
+
return {
|
|
2036
|
+
content: [{
|
|
2037
|
+
type: 'text' as const,
|
|
2038
|
+
text: JSON.stringify({
|
|
2039
|
+
mode,
|
|
2040
|
+
from: componentName,
|
|
2041
|
+
to: target,
|
|
2042
|
+
...path,
|
|
2043
|
+
edges: path.edges.map((edge) => ({
|
|
2044
|
+
source: edge.source,
|
|
2045
|
+
target: edge.target,
|
|
2046
|
+
type: edge.type,
|
|
2047
|
+
})),
|
|
2048
|
+
}, null, 2),
|
|
2049
|
+
}],
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
case 'composition': {
|
|
2054
|
+
const error = requireComponent(mode);
|
|
2055
|
+
if (error) throw new Error(error);
|
|
2056
|
+
const composition = engine.composition(componentName!);
|
|
2057
|
+
return {
|
|
2058
|
+
content: [{
|
|
2059
|
+
type: 'text' as const,
|
|
2060
|
+
text: JSON.stringify({
|
|
2061
|
+
mode,
|
|
2062
|
+
...composition,
|
|
2063
|
+
}, null, 2),
|
|
2064
|
+
}],
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
case 'alternatives': {
|
|
2069
|
+
const error = requireComponent(mode);
|
|
2070
|
+
if (error) throw new Error(error);
|
|
2071
|
+
const alternatives = engine.alternatives(componentName!);
|
|
2072
|
+
return {
|
|
2073
|
+
content: [{
|
|
2074
|
+
type: 'text' as const,
|
|
2075
|
+
text: JSON.stringify({
|
|
2076
|
+
mode,
|
|
2077
|
+
component: componentName,
|
|
2078
|
+
count: alternatives.length,
|
|
2079
|
+
alternatives,
|
|
2080
|
+
}, null, 2),
|
|
2081
|
+
}],
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
case 'islands': {
|
|
2086
|
+
const islands = engine.islands();
|
|
2087
|
+
return {
|
|
2088
|
+
content: [{
|
|
2089
|
+
type: 'text' as const,
|
|
2090
|
+
text: JSON.stringify({
|
|
2091
|
+
mode,
|
|
2092
|
+
count: islands.length,
|
|
2093
|
+
islands: islands.map((island, index) => ({
|
|
2094
|
+
id: index + 1,
|
|
2095
|
+
size: island.length,
|
|
2096
|
+
components: island,
|
|
2097
|
+
})),
|
|
2098
|
+
}, null, 2),
|
|
2099
|
+
}],
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
default:
|
|
2104
|
+
throw new Error(`Unknown mode: "${mode}". Valid modes: dependencies, dependents, impact, path, composition, alternatives, islands, health`);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// ================================================================
|
|
2109
|
+
// A11Y — run accessibility audit against viewer endpoint
|
|
2110
|
+
// ================================================================
|
|
2111
|
+
case TOOL_NAMES.a11y: {
|
|
2112
|
+
const data = await loadFragments();
|
|
2113
|
+
const componentName = args?.component as string;
|
|
2114
|
+
const variantName = (args?.variant as string) ?? undefined;
|
|
2115
|
+
const standard = (args?.standard as 'AA' | 'AAA') ?? 'AA';
|
|
2116
|
+
|
|
2117
|
+
if (!componentName) {
|
|
2118
|
+
throw new Error('component is required');
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const fragment = Object.values(data.fragments).find(
|
|
2122
|
+
(entry) => entry.meta.name.toLowerCase() === componentName.toLowerCase()
|
|
2123
|
+
);
|
|
2124
|
+
if (!fragment) {
|
|
2125
|
+
throw new Error(`Component "${componentName}" not found. Use ${TOOL_NAMES.discover} to see available components.`);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
const baseUrl = config.viewerUrl ?? 'http://localhost:6006';
|
|
2129
|
+
const auditUrl = `${baseUrl}/fragments/a11y`;
|
|
2130
|
+
|
|
2131
|
+
try {
|
|
2132
|
+
const response = await fetch(auditUrl, {
|
|
2133
|
+
method: 'POST',
|
|
2134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2135
|
+
body: JSON.stringify({
|
|
2136
|
+
component: componentName,
|
|
2137
|
+
variant: variantName,
|
|
2138
|
+
standard,
|
|
2139
|
+
}),
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
const result = await response.json() as {
|
|
2143
|
+
results?: Array<{
|
|
2144
|
+
variant: string;
|
|
2145
|
+
violations: number;
|
|
2146
|
+
passes: number;
|
|
2147
|
+
incomplete: number;
|
|
2148
|
+
summary: {
|
|
2149
|
+
total: number;
|
|
2150
|
+
critical: number;
|
|
2151
|
+
serious: number;
|
|
2152
|
+
moderate: number;
|
|
2153
|
+
minor: number;
|
|
2154
|
+
};
|
|
2155
|
+
violationDetails?: Array<{
|
|
2156
|
+
id: string;
|
|
2157
|
+
impact?: string;
|
|
2158
|
+
description: string;
|
|
2159
|
+
helpUrl: string;
|
|
2160
|
+
nodes: number;
|
|
2161
|
+
}>;
|
|
2162
|
+
}>;
|
|
2163
|
+
error?: string;
|
|
2164
|
+
};
|
|
2165
|
+
|
|
2166
|
+
if (!response.ok || result.error) {
|
|
2167
|
+
throw new Error(result.error ?? 'A11y audit failed');
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const variants = result.results ?? [];
|
|
2171
|
+
const summary = variants.reduce(
|
|
2172
|
+
(acc, variant) => {
|
|
2173
|
+
acc.totalViolations += variant.violations;
|
|
2174
|
+
acc.totalPasses += variant.passes;
|
|
2175
|
+
acc.totalIncomplete += variant.incomplete;
|
|
2176
|
+
acc.critical += variant.summary.critical;
|
|
2177
|
+
acc.serious += variant.summary.serious;
|
|
2178
|
+
acc.moderate += variant.summary.moderate;
|
|
2179
|
+
acc.minor += variant.summary.minor;
|
|
2180
|
+
return acc;
|
|
2181
|
+
},
|
|
2182
|
+
{
|
|
2183
|
+
totalViolations: 0,
|
|
2184
|
+
totalPasses: 0,
|
|
2185
|
+
totalIncomplete: 0,
|
|
2186
|
+
critical: 0,
|
|
2187
|
+
serious: 0,
|
|
2188
|
+
moderate: 0,
|
|
2189
|
+
minor: 0,
|
|
2190
|
+
}
|
|
2191
|
+
);
|
|
2192
|
+
|
|
2193
|
+
const totalChecks = summary.totalPasses + summary.totalViolations + summary.totalIncomplete;
|
|
2194
|
+
const wcagScore = totalChecks > 0
|
|
2195
|
+
? Math.round((summary.totalPasses / totalChecks) * 100)
|
|
2196
|
+
: 100;
|
|
2197
|
+
|
|
2198
|
+
return {
|
|
2199
|
+
content: [{
|
|
2200
|
+
type: 'text' as const,
|
|
2201
|
+
text: JSON.stringify({
|
|
2202
|
+
component: componentName,
|
|
2203
|
+
standard,
|
|
2204
|
+
wcagScore,
|
|
2205
|
+
passed: summary.critical === 0 && summary.serious === 0,
|
|
2206
|
+
summary,
|
|
2207
|
+
variants,
|
|
2208
|
+
}, null, 2),
|
|
2209
|
+
}],
|
|
2210
|
+
};
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
return {
|
|
2213
|
+
content: [{
|
|
2214
|
+
type: 'text' as const,
|
|
2215
|
+
text: `Failed to run accessibility audit: ${error instanceof Error ? error.message : 'Unknown error'}. Make sure the Fragments dev server is running.`,
|
|
2216
|
+
}],
|
|
2217
|
+
isError: true,
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// ================================================================
|
|
2223
|
+
// GENERATE_UI — delegate to playground generation endpoint
|
|
2224
|
+
// ================================================================
|
|
2225
|
+
case TOOL_NAMES.generate_ui: {
|
|
2226
|
+
const prompt = args?.prompt as string;
|
|
2227
|
+
if (!prompt) {
|
|
2228
|
+
throw new Error('prompt is required');
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const currentTree = args?.currentTree as Record<string, unknown> | undefined;
|
|
2232
|
+
const playgroundUrl = 'https://usefragments.com';
|
|
2233
|
+
const response = await fetch(`${playgroundUrl}/api/playground/generate`, {
|
|
2234
|
+
method: 'POST',
|
|
2235
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2236
|
+
body: JSON.stringify({
|
|
2237
|
+
prompt,
|
|
2238
|
+
...(currentTree && { currentSpec: currentTree }),
|
|
2239
|
+
}),
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
if (!response.ok) {
|
|
2243
|
+
throw new Error(`Playground API error (${response.status}): ${await response.text()}`);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
return {
|
|
2247
|
+
content: [{
|
|
2248
|
+
type: 'text' as const,
|
|
2249
|
+
text: await response.text(),
|
|
2250
|
+
}],
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// ================================================================
|
|
2255
|
+
// GOVERN — validate generated UI specs against governance policies
|
|
2256
|
+
// ================================================================
|
|
2257
|
+
case TOOL_NAMES.govern: {
|
|
2258
|
+
const data = await loadFragments();
|
|
2259
|
+
const spec = args?.spec;
|
|
2260
|
+
if (!spec || typeof spec !== 'object') {
|
|
2261
|
+
return {
|
|
2262
|
+
content: [{
|
|
2263
|
+
type: 'text' as const,
|
|
2264
|
+
text: JSON.stringify({
|
|
2265
|
+
error: 'spec is required and must be an object with { nodes: [{ id, type, props, children }] }',
|
|
2266
|
+
}),
|
|
2267
|
+
}],
|
|
2268
|
+
isError: true,
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const {
|
|
2273
|
+
handleGovernTool,
|
|
2274
|
+
formatVerdict,
|
|
2275
|
+
universal,
|
|
2276
|
+
fragments: fragmentsPreset,
|
|
2277
|
+
} = await import('@fragments-sdk/govern');
|
|
2278
|
+
|
|
2279
|
+
const policyOverrides = args?.policy as Record<string, unknown> | undefined;
|
|
2280
|
+
const format = (args?.format as 'json' | 'summary') ?? 'json';
|
|
2281
|
+
const tokenPrefix = data.tokens?.prefix;
|
|
2282
|
+
const basePolicy =
|
|
2283
|
+
tokenPrefix && tokenPrefix.includes('fui')
|
|
2284
|
+
? { rules: fragmentsPreset().rules }
|
|
2285
|
+
: { rules: universal().rules };
|
|
2286
|
+
const engineOptions: GovernEngineOptions | undefined = data.tokens
|
|
2287
|
+
? { tokenData: data.tokens }
|
|
2288
|
+
: undefined;
|
|
2289
|
+
|
|
2290
|
+
const input: McpGovernInput = {
|
|
2291
|
+
spec,
|
|
2292
|
+
policy: policyOverrides as McpGovernInput['policy'],
|
|
2293
|
+
format,
|
|
2294
|
+
};
|
|
2295
|
+
|
|
2296
|
+
try {
|
|
2297
|
+
const verdict = await handleGovernTool(input, basePolicy, engineOptions);
|
|
2298
|
+
return {
|
|
2299
|
+
content: [{
|
|
2300
|
+
type: 'text' as const,
|
|
2301
|
+
text: format === 'summary'
|
|
2302
|
+
? formatVerdict(verdict, 'summary')
|
|
2303
|
+
: JSON.stringify(verdict),
|
|
2304
|
+
}],
|
|
2305
|
+
_meta: {
|
|
2306
|
+
score: verdict.score,
|
|
2307
|
+
passed: verdict.passed,
|
|
2308
|
+
violationCount: verdict.results.reduce((sum, result) => sum + result.violations.length, 0),
|
|
2309
|
+
},
|
|
2310
|
+
};
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2313
|
+
return {
|
|
2314
|
+
content: [{
|
|
2315
|
+
type: 'text' as const,
|
|
2316
|
+
text: JSON.stringify({
|
|
2317
|
+
error: message,
|
|
2318
|
+
}),
|
|
2319
|
+
}],
|
|
2320
|
+
isError: true,
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
1539
2325
|
// ================================================================
|
|
1540
2326
|
// PERF — query performance data
|
|
1541
2327
|
// ================================================================
|