@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/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(BRAND.nameLower) as Record<string, string> & {
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: '0.0.1',
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) ?? undefined;
486
- const search = (args?.search as string)?.toLowerCase() ?? undefined;
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: compact or format specified with no specific query ---
494
- if (compact || (args?.format && !useCase && !componentForAlts && !category && !search && !status)) {
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}`.split(/\s+/).filter(Boolean);
521
-
522
- const synonymMap: Record<string, string[]> = {
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 (Array.from(expandedTerms).some((term) => nameLower.includes(term))) {
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 = Array.from(expandedTerms).filter(
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 >= 5) break;
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 STYLE_KEYWORDS = ['color', 'spacing', 'padding', 'margin', 'font', 'border', 'radius', 'shadow', 'variable', 'token', 'css', 'theme', 'dark mode', 'background', 'hover'];
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
- : 'No matching components found. Try different keywords or browse all components with fragments_discover.';
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: suggestions.map(({ score, ...rest }) => rest),
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 fragments_discover to see available components.`);
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
- .map((s) => ({
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: fragments.length,
782
- fragments,
783
- categories: [...new Set(fragments.map((s) => s.category))],
784
- hint: fragments.length === 0
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
- : fragments.length > 5
787
- ? 'Use fragments_discover with useCase for recommendations, or fragments_inspect for details on a specific component.'
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 fragments_discover to see available components.`);
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)?.toLowerCase() ?? undefined;
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)?.toLowerCase() ?? undefined;
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.filter(b => {
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.toLowerCase() === 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: filtered.length,
997
- blocks: filtered,
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)?.toLowerCase() ?? undefined;
1009
- const search = (args?.search as string)?.toLowerCase() ?? undefined;
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
- let filteredTotal = 0;
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
- // Filter by category
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
- filtered = tokens.filter(
1038
- (t) => t.name.toLowerCase().includes(search) ||
1039
- (t.description && t.description.toLowerCase().includes(search))
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: ${availableCategories.join(', ')}`
1412
+ ? `No tokens matching "${search}". Try categories like: ${availableCategoryNames.join(', ')}`
1055
1413
  : category
1056
- ? `Category "${category}" not found. Available: ${availableCategories.join(', ')}`
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: filteredCategories,
1427
+ categories: limited.categories,
1070
1428
  ...(hint && { hint }),
1071
1429
  ...((!category && !search) && {
1072
- availableCategories: Object.entries(tokenData.categories).map(
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.split(/\s+/).filter(Boolean);
1093
-
1094
- // 1. Score all fragments (same logic as discover suggest)
1095
- const synonymMap: Record<string, string[]> = {
1096
- 'form': ['input', 'field', 'submit', 'validation'],
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 (Array.from(expandedTerms).some((t) => nameLower.includes(t))) score += 8;
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 += Array.from(expandedTerms).filter((t) => !searchTerms.includes(t) && whenUsed.includes(t)).length * 5;
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, 3);
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 examples = s.variants.slice(0, 2).map((v) => ({
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, 10).map(
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: score >= 25 ? 'high' : score >= 15 ? 'medium' : 'low',
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 matchingBlocks = allBlocks
1166
- .filter((b) => {
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((b) => ({ name: b.name, description: b.description, components: b.components, code: b.code }));
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 STYLE_KEYWORDS = ['color', 'spacing', 'padding', 'margin', 'font', 'border', 'radius', 'shadow', 'background', 'hover', 'theme'];
1179
- const styleTerms = searchTerms.filter((t) => STYLE_KEYWORDS.includes(t));
1180
- if (styleTerms.length > 0) {
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 [cat, tokens] of Object.entries(tokenData.categories)) {
1183
- const matching = tokens.filter((t) => styleTerms.some((st) => t.name.includes(st) || cat.includes(st)));
1184
- if (matching.length > 0) {
1185
- relevantTokens[cat] = matching.map((t) => t.name);
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 fragments_discover to see available components.`);
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
- ? 'Apply patches using your editor or `patch` command, then run fragments_render with baseline:true to confirm fixes.'
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
  // ================================================================