@monoharada/wcf-mcp 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * and helper functions live in exactly one place.
7
7
  */
8
8
 
9
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { z } from 'zod';
11
11
 
12
12
  // ---------------------------------------------------------------------------
@@ -17,6 +17,8 @@ export const CANONICAL_PREFIX = 'dads';
17
17
  export const MAX_PREFIX_LENGTH = 64;
18
18
  export const STRUCTURED_CONTENT_DISABLE_FLAG = 'WCF_MCP_DISABLE_STRUCTURED_CONTENT';
19
19
  export const MAX_TOOL_RESULT_BYTES = 100 * 1024;
20
+ export const PLUGIN_TOOL_NOTICE = 'Plugin tool (contract v1).';
21
+ export const PLUGIN_CONTRACT_VERSION = '1.0.0';
20
22
 
21
23
  export const CATEGORY_MAP = {
22
24
  'dads-input-text': 'Form',
@@ -85,6 +87,32 @@ export const CATEGORY_MAP = {
85
87
  const TOKEN_MISUSE_ALLOWED_TYPES = Object.freeze(new Set(['color', 'spacing']));
86
88
  const STRUCTURED_CONTENT_DISABLE_TRUE_VALUES = Object.freeze(new Set(['1', 'true', 'yes', 'on']));
87
89
  const WCAG_LEVELS = Object.freeze(new Set(['A', 'AA', 'AAA', 'all']));
90
+ const TOKEN_THEMES = Object.freeze(new Set(['light', 'dark', 'all']));
91
+ const GUIDELINE_TOPICS = Object.freeze(['accessibility', 'css', 'patterns', 'all']);
92
+ const GUIDELINE_TOPIC_SET = Object.freeze(new Set(GUIDELINE_TOPICS));
93
+ const PLUGIN_DATA_SOURCE_KEYS = Object.freeze(new Set([
94
+ 'custom-elements.json',
95
+ 'install-registry.json',
96
+ 'pattern-registry.json',
97
+ 'design-tokens.json',
98
+ 'guidelines-index.json',
99
+ ]));
100
+ const BUILTIN_TOOL_NAMES = Object.freeze(new Set([
101
+ 'get_design_system_overview',
102
+ 'list_components',
103
+ 'search_icons',
104
+ 'get_component_api',
105
+ 'generate_usage_snippet',
106
+ 'get_install_recipe',
107
+ 'validate_markup',
108
+ 'list_patterns',
109
+ 'get_pattern_recipe',
110
+ 'generate_pattern_snippet',
111
+ 'get_design_tokens',
112
+ 'get_design_token_detail',
113
+ 'get_accessibility_docs',
114
+ 'search_guidelines',
115
+ ]));
88
116
  const A11Y_CATEGORY_LEVEL_MAP = Object.freeze({
89
117
  semantics: 'A',
90
118
  keyboard: 'A',
@@ -99,6 +127,40 @@ const NPX_TEMPLATE = Object.freeze({
99
127
  command: 'npx',
100
128
  args: ['@monoharada/wcf-mcp'],
101
129
  });
130
+ export const FIGMA_TO_WCF_PROMPT = 'figma_to_wcf';
131
+ export const WCF_RESOURCE_URIS = Object.freeze({
132
+ components: 'wcf://components',
133
+ tokens: 'wcf://tokens',
134
+ guidelinesTemplate: 'wcf://guidelines/{topic}',
135
+ llmsFull: 'wcf://llms-full',
136
+ });
137
+
138
+ // Unidirectional synonym table: key → expands to include these terms (DIG-09)
139
+ // Searching "keyboard" also matches "focus", "tab" etc. but NOT reverse.
140
+ const SYNONYM_TABLE = new Map([
141
+ ['aria-live', ['role=alert', 'aria-describedby', 'live region', 'error text']],
142
+ ['keyboard', ['focus', 'tab', 'tabindex', 'key event', 'focus trap']],
143
+ ['contrast', ['color', 'wcag', 'color contrast']],
144
+ ['spacing', ['margin', 'padding', 'gap', 'spacing token']],
145
+ ['skip-navigation', ['skip-link', 'landmark', 'skip nav']],
146
+ ['heading', ['heading hierarchy', 'h1', 'heading level']],
147
+ ['form', ['input', 'validation', 'required', 'label']],
148
+ ]);
149
+
150
+ export function expandQueryWithSynonyms(query) {
151
+ const q = String(query ?? '').toLowerCase().trim();
152
+ if (!q) return [q];
153
+ const terms = [q];
154
+ for (const [key, synonyms] of SYNONYM_TABLE) {
155
+ if (q.includes(key)) {
156
+ for (const syn of synonyms) {
157
+ if (!terms.includes(syn)) terms.push(syn);
158
+ }
159
+ }
160
+ }
161
+ return terms;
162
+ }
163
+
102
164
  export const IDE_SETUP_TEMPLATES = Object.freeze([
103
165
  {
104
166
  ide: 'Claude Desktop',
@@ -127,6 +189,24 @@ export const IDE_SETUP_TEMPLATES = Object.freeze([
127
189
  },
128
190
  },
129
191
  },
192
+ {
193
+ ide: 'VS Code (GitHub Copilot)',
194
+ configPath: '.vscode/mcp.json',
195
+ snippet: {
196
+ mcpServers: {
197
+ wcf: NPX_TEMPLATE,
198
+ },
199
+ },
200
+ },
201
+ {
202
+ ide: 'Windsurf',
203
+ configPath: '.windsurf/mcp_config.json',
204
+ snippet: {
205
+ mcpServers: {
206
+ wcf: NPX_TEMPLATE,
207
+ },
208
+ },
209
+ },
130
210
  ]);
131
211
 
132
212
  export function isStructuredContentDisabled(env = process.env) {
@@ -204,6 +284,406 @@ export function buildTokenSuggestionMap(designTokensData) {
204
284
  return out;
205
285
  }
206
286
 
287
+ export function normalizeTokenIdentifier(value) {
288
+ const raw = String(value ?? '').trim().toLowerCase();
289
+ if (!raw) return '';
290
+ const cssVariable = normalizeCssVariable(raw);
291
+ if (cssVariable) return cssVariable;
292
+ if (raw.startsWith('--')) return raw;
293
+ return `--${raw.replace(/^[-]+/, '')}`;
294
+ }
295
+
296
+ export function resolveTokenTheme(theme) {
297
+ const requested = String(theme ?? 'light').trim().toLowerCase() || 'light';
298
+ if (!TOKEN_THEMES.has(requested)) {
299
+ return {
300
+ ok: false,
301
+ errorCode: 'INVALID_THEME',
302
+ message: `Unsupported theme: ${requested}. Allowed values are light, dark, all.`,
303
+ };
304
+ }
305
+ if (requested !== 'light') {
306
+ return {
307
+ ok: false,
308
+ errorCode: 'INVALID_THEME',
309
+ message: `Theme "${requested}" is not available yet. Use theme="light" (NG-06).`,
310
+ };
311
+ }
312
+ return {
313
+ ok: true,
314
+ requested,
315
+ resolved: 'light',
316
+ available: ['light'],
317
+ };
318
+ }
319
+
320
+ export function extractReferencedTokenNames(value) {
321
+ if (typeof value !== 'string') return [];
322
+ const refs = [];
323
+ const re = /var\(\s*(--[^,\s)]+)\s*(?:,\s*[^)]+)?\)/g;
324
+ let match;
325
+ while ((match = re.exec(value))) {
326
+ const tokenName = normalizeTokenIdentifier(match[1]);
327
+ if (tokenName) refs.push(tokenName);
328
+ }
329
+ return [...new Set(refs)];
330
+ }
331
+
332
+ export function buildTokenRelationshipIndex(designTokensData) {
333
+ const byToken = {};
334
+ const tokens = Array.isArray(designTokensData?.tokens) ? designTokensData.tokens : [];
335
+ const fromData = designTokensData?.relationships?.byToken;
336
+ if (fromData && typeof fromData === 'object') {
337
+ for (const [rawName, rawRel] of Object.entries(fromData)) {
338
+ const name = normalizeTokenIdentifier(rawName);
339
+ if (!name) continue;
340
+ const refs = Array.isArray(rawRel?.references)
341
+ ? rawRel.references.map((r) => normalizeTokenIdentifier(r)).filter(Boolean)
342
+ : [];
343
+ const referencedBy = Array.isArray(rawRel?.referencedBy)
344
+ ? rawRel.referencedBy.map((r) => normalizeTokenIdentifier(r)).filter(Boolean)
345
+ : [];
346
+ byToken[name] = {
347
+ references: [...new Set(refs)].sort(),
348
+ referencedBy: [...new Set(referencedBy)].sort(),
349
+ };
350
+ }
351
+ }
352
+
353
+ if (Object.keys(byToken).length > 0) {
354
+ return { byToken };
355
+ }
356
+
357
+ for (const token of tokens) {
358
+ const name = normalizeTokenIdentifier(token?.name);
359
+ if (!name) continue;
360
+ if (!byToken[name]) byToken[name] = { references: [], referencedBy: [] };
361
+ const refs = extractReferencedTokenNames(token?.value);
362
+ byToken[name].references = refs;
363
+ }
364
+
365
+ for (const [sourceName, relation] of Object.entries(byToken)) {
366
+ for (const refName of relation.references) {
367
+ if (!byToken[refName]) byToken[refName] = { references: [], referencedBy: [] };
368
+ byToken[refName].referencedBy.push(sourceName);
369
+ }
370
+ }
371
+
372
+ for (const relation of Object.values(byToken)) {
373
+ relation.references = [...new Set(relation.references)].sort();
374
+ relation.referencedBy = [...new Set(relation.referencedBy)].sort();
375
+ }
376
+
377
+ return { byToken };
378
+ }
379
+
380
+ function toTokenSummary(token) {
381
+ return {
382
+ name: String(token?.name ?? ''),
383
+ value: String(token?.value ?? ''),
384
+ type: String(token?.type ?? ''),
385
+ category: String(token?.category ?? ''),
386
+ cssVariable: String(token?.cssVariable ?? ''),
387
+ };
388
+ }
389
+
390
+ export function suggestTokenNames(targetName, tokens, maxSuggestions = 5) {
391
+ const target = normalizeTokenIdentifier(targetName);
392
+ if (!target) return [];
393
+ const allNames = [...new Set(tokens
394
+ .map((token) => normalizeTokenIdentifier(token?.name))
395
+ .filter(Boolean))];
396
+
397
+ const startsWith = allNames.filter((name) => name.startsWith(target));
398
+ if (startsWith.length >= maxSuggestions) return startsWith.slice(0, maxSuggestions);
399
+
400
+ const includes = allNames.filter((name) => name.includes(target) && !startsWith.includes(name));
401
+ const ranked = allNames
402
+ .filter((name) => !startsWith.includes(name) && !includes.includes(name))
403
+ .map((name) => ({ name, distance: levenshteinDistance(target, name) }))
404
+ .sort((left, right) => left.distance - right.distance || left.name.localeCompare(right.name))
405
+ .map((entry) => entry.name);
406
+
407
+ return [...startsWith, ...includes, ...ranked].slice(0, maxSuggestions);
408
+ }
409
+
410
+ function buildUsageExamples(token) {
411
+ const cssVar = String(token?.cssVariable ?? '');
412
+ const type = String(token?.type ?? '').toLowerCase();
413
+ if (!cssVar) return [];
414
+ if (type === 'color') {
415
+ return [
416
+ `.example { color: ${cssVar}; }`,
417
+ `.example { background-color: ${cssVar}; }`,
418
+ ];
419
+ }
420
+ if (type === 'spacing') {
421
+ return [
422
+ `.example { padding: ${cssVar}; }`,
423
+ `.example { gap: ${cssVar}; }`,
424
+ ];
425
+ }
426
+ if (type === 'typography') {
427
+ return [
428
+ `.example { font-size: ${cssVar}; }`,
429
+ `.example { line-height: ${cssVar}; }`,
430
+ ];
431
+ }
432
+ if (type === 'radius') {
433
+ return [`.example { border-radius: ${cssVar}; }`];
434
+ }
435
+ if (type === 'shadow') {
436
+ return [`.example { box-shadow: ${cssVar}; }`];
437
+ }
438
+ return [`.example { --token-value: ${cssVar}; }`];
439
+ }
440
+
441
+ function buildTokenErrorPayload(code, message, extra = {}) {
442
+ return {
443
+ isError: true,
444
+ payload: {
445
+ error: { code, message },
446
+ ...extra,
447
+ },
448
+ };
449
+ }
450
+
451
+ export function buildDesignTokenDetailPayload(designTokensData, name, theme) {
452
+ if (!Array.isArray(designTokensData?.tokens)) {
453
+ return buildTokenErrorPayload(
454
+ 'DESIGN_TOKENS_DATA_UNAVAILABLE',
455
+ 'Design tokens data not available. Run: npm run mcp:extract-tokens',
456
+ );
457
+ }
458
+
459
+ const themeInfo = resolveTokenTheme(theme);
460
+ if (!themeInfo.ok) {
461
+ return buildTokenErrorPayload(themeInfo.errorCode, themeInfo.message);
462
+ }
463
+
464
+ const normalizedName = normalizeTokenIdentifier(name);
465
+ if (!normalizedName) {
466
+ return buildTokenErrorPayload('INVALID_TOKEN_INPUT', 'Token name is required.');
467
+ }
468
+
469
+ const tokens = designTokensData.tokens;
470
+ const token = tokens.find((item) => normalizeTokenIdentifier(item?.name) === normalizedName);
471
+ if (!token) {
472
+ return buildTokenErrorPayload(
473
+ 'TOKEN_NOT_FOUND',
474
+ `Token not found: ${normalizedName}`,
475
+ { suggestions: suggestTokenNames(normalizedName, tokens) },
476
+ );
477
+ }
478
+
479
+ const relationshipIndex = buildTokenRelationshipIndex(designTokensData);
480
+ const relation = relationshipIndex.byToken[normalizedName] ?? { references: [], referencedBy: [] };
481
+ const tokenByName = new Map(tokens
482
+ .map((item) => [normalizeTokenIdentifier(item?.name), item])
483
+ .filter(([tokenName]) => tokenName));
484
+ const references = relation.references
485
+ .map((tokenName) => tokenByName.get(tokenName))
486
+ .filter(Boolean)
487
+ .map(toTokenSummary);
488
+ const referencedBy = relation.referencedBy
489
+ .map((tokenName) => tokenByName.get(tokenName))
490
+ .filter(Boolean)
491
+ .map(toTokenSummary);
492
+ const relatedTokens = referencedBy
493
+ .filter((item) => String(item.category).toLowerCase() === 'semantic')
494
+ .map((item) => item.name);
495
+
496
+ return {
497
+ isError: false,
498
+ payload: {
499
+ token: {
500
+ ...toTokenSummary(token),
501
+ group: token?.group ?? null,
502
+ },
503
+ references,
504
+ referencedBy,
505
+ relatedTokens,
506
+ usageExamples: buildUsageExamples(token),
507
+ theme: {
508
+ requested: themeInfo.requested,
509
+ resolved: themeInfo.resolved,
510
+ available: themeInfo.available,
511
+ },
512
+ },
513
+ };
514
+ }
515
+
516
+ export function buildDesignTokensPayload(designTokensData, { type, category, query, theme } = {}) {
517
+ if (!designTokensData) {
518
+ return buildTokenErrorPayload(
519
+ 'DESIGN_TOKENS_DATA_UNAVAILABLE',
520
+ 'Design tokens data not available. Run: npm run mcp:extract-tokens',
521
+ );
522
+ }
523
+
524
+ const themeInfo = resolveTokenTheme(theme);
525
+ if (!themeInfo.ok) {
526
+ return buildTokenErrorPayload(themeInfo.errorCode, themeInfo.message);
527
+ }
528
+
529
+ let tokens = Array.isArray(designTokensData.tokens) ? designTokensData.tokens : [];
530
+ if (type) tokens = tokens.filter((t) => t.type === type);
531
+ if (category) tokens = tokens.filter((t) => t.category === category);
532
+ if (query) {
533
+ const q = String(query).toLowerCase();
534
+ tokens = tokens.filter((t) => String(t.name ?? '').toLowerCase().includes(q));
535
+ }
536
+
537
+ return {
538
+ isError: false,
539
+ payload: {
540
+ total: tokens.length,
541
+ tokens,
542
+ summary: designTokensData.summary,
543
+ theme: {
544
+ requested: themeInfo.requested,
545
+ resolved: themeInfo.resolved,
546
+ available: themeInfo.available,
547
+ },
548
+ },
549
+ };
550
+ }
551
+
552
+ function isPlainObject(value) {
553
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
554
+ }
555
+
556
+ function toPluginErrorMessage(name, reason) {
557
+ return `Invalid plugin (${name}): ${reason}`;
558
+ }
559
+
560
+ /**
561
+ * Plugin contract v1 — stable interface. See docs/plugin-contract-v1.md.
562
+ * @typedef {{
563
+ * fileName: string,
564
+ * path: string,
565
+ * }} WcfMcpDataSourceConfig
566
+ */
567
+
568
+ /**
569
+ * Plugin contract v1 — stable interface. See docs/plugin-contract-v1.md.
570
+ * @typedef {{
571
+ * name: string,
572
+ * description?: string,
573
+ * inputSchema?: Record<string, unknown>,
574
+ * handler?: (args: Record<string, unknown>, context: { plugin: { name: string, version: string }, helpers: { loadJsonData: Function } }) => unknown,
575
+ * staticPayload?: unknown,
576
+ * }} WcfMcpPluginTool
577
+ */
578
+
579
+ /**
580
+ * Plugin contract v1 — stable interface. See docs/plugin-contract-v1.md.
581
+ * @typedef {{
582
+ * name: string,
583
+ * version: string,
584
+ * tools?: WcfMcpPluginTool[],
585
+ * dataSources?: WcfMcpDataSourceConfig[],
586
+ * }} WcfMcpPlugin
587
+ */
588
+
589
+ function normalizePluginDataSources(pluginName, dataSources) {
590
+ if (!Array.isArray(dataSources)) return [];
591
+ const out = [];
592
+ for (const entry of dataSources) {
593
+ if (!isPlainObject(entry)) {
594
+ throw new Error(toPluginErrorMessage(pluginName, 'dataSources entries must be objects'));
595
+ }
596
+ const fileName = String(entry.fileName ?? '').trim();
597
+ const sourcePath = String(entry.path ?? '').trim();
598
+ if (!fileName || !sourcePath) {
599
+ throw new Error(toPluginErrorMessage(pluginName, 'dataSources entries require fileName and path'));
600
+ }
601
+ if (!PLUGIN_DATA_SOURCE_KEYS.has(fileName)) {
602
+ throw new Error(toPluginErrorMessage(pluginName, `unsupported data source key: ${fileName}`));
603
+ }
604
+ out.push({ fileName, path: sourcePath });
605
+ }
606
+ return out;
607
+ }
608
+
609
+ function normalizePluginTools(pluginName, tools) {
610
+ if (!Array.isArray(tools)) return [];
611
+ const out = [];
612
+ for (const rawTool of tools) {
613
+ if (!isPlainObject(rawTool)) {
614
+ throw new Error(toPluginErrorMessage(pluginName, 'tools entries must be objects'));
615
+ }
616
+ const name = String(rawTool.name ?? '').trim();
617
+ if (!name) throw new Error(toPluginErrorMessage(pluginName, 'tool.name is required'));
618
+ const hasHandler = typeof rawTool.handler === 'function';
619
+ const hasStaticPayload = Object.prototype.hasOwnProperty.call(rawTool, 'staticPayload');
620
+ if (!hasHandler && !hasStaticPayload) {
621
+ throw new Error(toPluginErrorMessage(pluginName, `tool "${name}" needs handler or staticPayload`));
622
+ }
623
+ // When both are specified, handler takes priority (contract v1: handler-wins)
624
+ // staticPayload is ignored silently.
625
+ const description = String(rawTool.description ?? '').trim() ||
626
+ `Plugin tool provided by ${pluginName}. ${PLUGIN_TOOL_NOTICE}`;
627
+ const inputSchema = isPlainObject(rawTool.inputSchema) ? rawTool.inputSchema : {};
628
+ out.push({
629
+ name,
630
+ description,
631
+ inputSchema,
632
+ handler: hasHandler ? rawTool.handler : undefined,
633
+ staticPayload: hasStaticPayload ? rawTool.staticPayload : undefined,
634
+ });
635
+ }
636
+ return out;
637
+ }
638
+
639
+ export function normalizePlugins(plugins = []) {
640
+ if (!Array.isArray(plugins)) throw new Error('Invalid plugin configuration: plugins must be an array');
641
+ const normalized = [];
642
+ const seenPluginNames = new Set();
643
+ const seenToolNames = new Set(BUILTIN_TOOL_NAMES);
644
+
645
+ for (const rawPlugin of plugins) {
646
+ if (!isPlainObject(rawPlugin)) throw new Error('Invalid plugin configuration: each plugin must be an object');
647
+ const name = String(rawPlugin.name ?? '').trim();
648
+ const version = String(rawPlugin.version ?? '').trim();
649
+ if (!name || !version) throw new Error('Invalid plugin configuration: plugin.name and plugin.version are required');
650
+ if (seenPluginNames.has(name)) throw new Error(`Duplicate plugin name: ${name}`);
651
+ seenPluginNames.add(name);
652
+
653
+ const tools = normalizePluginTools(name, rawPlugin.tools);
654
+ for (const tool of tools) {
655
+ if (seenToolNames.has(tool.name)) {
656
+ throw new Error(toPluginErrorMessage(name, `tool name collision: ${tool.name}`));
657
+ }
658
+ seenToolNames.add(tool.name);
659
+ }
660
+
661
+ const dataSources = normalizePluginDataSources(name, rawPlugin.dataSources);
662
+ normalized.push({ name, version, tools, dataSources });
663
+ }
664
+
665
+ return normalized;
666
+ }
667
+
668
+ export function buildPluginDataSourceMap(plugins = []) {
669
+ const out = new Map();
670
+ for (const plugin of plugins) {
671
+ const pluginName = String(plugin?.name ?? 'unknown-plugin');
672
+ const dataSources = Array.isArray(plugin?.dataSources) ? plugin.dataSources : [];
673
+ for (const source of dataSources) {
674
+ const fileName = String(source?.fileName ?? '').trim();
675
+ const sourcePath = String(source?.path ?? '').trim();
676
+ if (!fileName || !sourcePath) continue;
677
+ if (out.has(fileName)) {
678
+ const prev = out.get(fileName);
679
+ throw new Error(`Duplicate data source override for ${fileName} (${prev.pluginName}, ${pluginName})`);
680
+ }
681
+ out.set(fileName, { path: sourcePath, pluginName });
682
+ }
683
+ }
684
+ return out;
685
+ }
686
+
207
687
  // ---------------------------------------------------------------------------
208
688
  // Helpers (exported for testing)
209
689
  // ---------------------------------------------------------------------------
@@ -480,18 +960,29 @@ export function findDeclByComponentId(indexes, componentIdRaw) {
480
960
  return undefined;
481
961
  }
482
962
 
483
- export function applyPrefixToCemIndex(cemIndex, prefix) {
963
+ /**
964
+ * Generic helper: remap tag-keyed Map to a different prefix.
965
+ * Used by validate_markup to build prefix-aware CEM/enum/slot maps.
966
+ */
967
+ export function applyPrefixToTagMap(map, prefix) {
484
968
  const p = normalizePrefix(prefix);
485
- if (p === CANONICAL_PREFIX) return cemIndex;
969
+ if (p === CANONICAL_PREFIX) return map;
486
970
 
487
971
  const out = new Map();
488
- for (const [tag, meta] of cemIndex.entries()) {
489
- const nextTag = withPrefix(tag, p);
490
- out.set(nextTag, meta);
972
+ for (const [tag, value] of map.entries()) {
973
+ out.set(withPrefix(tag, p), value);
491
974
  }
492
975
  return out;
493
976
  }
494
977
 
978
+ function mergeWithPrefixed(canonicalMap, prefix) {
979
+ const prefixed = applyPrefixToTagMap(canonicalMap, prefix);
980
+ if (prefixed === canonicalMap) return canonicalMap;
981
+ const combined = new Map(canonicalMap);
982
+ for (const [k, v] of prefixed.entries()) combined.set(k, v);
983
+ return combined;
984
+ }
985
+
495
986
  export function applyPrefixToHtml(html, prefix) {
496
987
  const p = normalizePrefix(prefix);
497
988
  if (p === CANONICAL_PREFIX) return String(html ?? '');
@@ -534,7 +1025,8 @@ export function resolveComponentClosure({ installRegistry }, componentIds) {
534
1025
  export function buildComponentSummaries(indexes, { category, query, limit, offset, prefix } = {}) {
535
1026
  const p = normalizePrefix(prefix);
536
1027
  const q = typeof query === 'string' ? query.trim().toLowerCase() : '';
537
- const pageSize = Number.isInteger(limit) ? Math.max(1, Math.min(limit, 200)) : Number.MAX_SAFE_INTEGER;
1028
+ const limitExplicit = Number.isInteger(limit);
1029
+ const pageSize = limitExplicit ? Math.max(1, Math.min(limit, 200)) : 20;
538
1030
  const pageOffset = Number.isInteger(offset) ? Math.max(0, offset) : 0;
539
1031
 
540
1032
  let items = indexes.decls.map(({ decl, tagName, modulePath }) => ({
@@ -565,13 +1057,20 @@ export function buildComponentSummaries(indexes, { category, query, limit, offse
565
1057
  const total = items.length;
566
1058
  const paged = items.slice(pageOffset, pageOffset + pageSize);
567
1059
 
568
- return {
1060
+ const result = {
569
1061
  total,
570
1062
  limit: pageSize,
571
1063
  offset: pageOffset,
572
1064
  hasMore: pageOffset + paged.length < total,
573
1065
  items: paged,
574
1066
  };
1067
+
1068
+ // DIG-19: Add migration notice when limit is not explicitly provided
1069
+ if (!limitExplicit && total > pageSize) {
1070
+ result._notice = 'Default pagination changed to 20 items. Set limit:200 for all results.';
1071
+ }
1072
+
1073
+ return result;
575
1074
  }
576
1075
 
577
1076
  export function parseIconNamesFromDescription(description) {
@@ -897,6 +1396,123 @@ export function queryAccessibilityIndex(
897
1396
  };
898
1397
  }
899
1398
 
1399
+ function buildComponentsResourcePayload(indexes) {
1400
+ const page = buildComponentSummaries(indexes, { limit: 200 });
1401
+ const componentsByCategory = {};
1402
+ for (const item of page.items) {
1403
+ const category = String(item?.category ?? 'Other');
1404
+ componentsByCategory[category] = (componentsByCategory[category] ?? 0) + 1;
1405
+ }
1406
+ return {
1407
+ total: page.total,
1408
+ componentsByCategory,
1409
+ components: page.items,
1410
+ };
1411
+ }
1412
+
1413
+ function buildTokensResourcePayload(designTokensData) {
1414
+ if (!Array.isArray(designTokensData?.tokens)) {
1415
+ return {
1416
+ isError: true,
1417
+ error: {
1418
+ code: 'DESIGN_TOKENS_DATA_UNAVAILABLE',
1419
+ message: 'Design tokens data not available. Run: npm run mcp:extract-tokens',
1420
+ },
1421
+ };
1422
+ }
1423
+
1424
+ const tokens = designTokensData.tokens;
1425
+ const tokenTypes = [...new Set(tokens
1426
+ .map((token) => String(token?.type ?? '').trim())
1427
+ .filter(Boolean))].sort();
1428
+ const tokenCategories = [...new Set(tokens
1429
+ .map((token) => String(token?.category ?? '').trim())
1430
+ .filter(Boolean))].sort();
1431
+
1432
+ return {
1433
+ isError: false,
1434
+ payload: {
1435
+ total: tokens.length,
1436
+ summary: designTokensData.summary ?? {},
1437
+ themes: designTokensData.themes ?? { default: 'light', available: ['light'] },
1438
+ tokenTypes,
1439
+ tokenCategories,
1440
+ sample: tokens.slice(0, 20).map(toTokenSummary),
1441
+ },
1442
+ };
1443
+ }
1444
+
1445
+ function buildGuidelinesResourcePayload(guidelinesIndexData, rawTopic) {
1446
+ const topic = String(rawTopic ?? '').trim().toLowerCase();
1447
+ if (!GUIDELINE_TOPIC_SET.has(topic)) {
1448
+ return {
1449
+ isError: true,
1450
+ error: {
1451
+ code: 'INVALID_GUIDELINE_TOPIC',
1452
+ message: `Unsupported topic: ${topic}. Allowed values are ${GUIDELINE_TOPICS.join(', ')}.`,
1453
+ },
1454
+ };
1455
+ }
1456
+
1457
+ if (!Array.isArray(guidelinesIndexData?.documents)) {
1458
+ return {
1459
+ isError: true,
1460
+ error: {
1461
+ code: 'GUIDELINES_INDEX_UNAVAILABLE',
1462
+ message: 'Guidelines index not available. Run: npm run mcp:index-guidelines',
1463
+ },
1464
+ };
1465
+ }
1466
+
1467
+ const documents = guidelinesIndexData.documents
1468
+ .filter((doc) => topic === 'all' || String(doc?.topic ?? '').toLowerCase() === topic)
1469
+ .map((doc) => {
1470
+ const sections = Array.isArray(doc?.sections) ? doc.sections : [];
1471
+ return {
1472
+ id: String(doc?.id ?? ''),
1473
+ title: String(doc?.title ?? ''),
1474
+ topic: String(doc?.topic ?? ''),
1475
+ sectionCount: sections.length,
1476
+ sections: sections.map((section) => ({
1477
+ heading: String(section?.heading ?? ''),
1478
+ startLine: Number.isInteger(section?.startLine) ? section.startLine : undefined,
1479
+ })),
1480
+ };
1481
+ });
1482
+
1483
+ return {
1484
+ isError: false,
1485
+ payload: {
1486
+ topic,
1487
+ totalDocuments: documents.length,
1488
+ topicCounts: guidelinesIndexData.topicCounts ?? {},
1489
+ documents,
1490
+ },
1491
+ };
1492
+ }
1493
+
1494
+ function buildFigmaToWcfPromptText({ figmaUrl, userIntent }) {
1495
+ const url = String(figmaUrl ?? '').trim();
1496
+ const intent = String(userIntent ?? '').trim();
1497
+
1498
+ return [
1499
+ `Figma URL: ${url}`,
1500
+ intent ? `Implementation goal: ${intent}` : 'Implementation goal: (not specified)',
1501
+ '',
1502
+ 'Use the workflow below in this exact order:',
1503
+ '1. get_design_system_overview',
1504
+ '2. get_design_tokens',
1505
+ '3. get_component_api',
1506
+ '4. generate_usage_snippet (or get_pattern_recipe)',
1507
+ '5. validate_markup',
1508
+ '',
1509
+ 'Output requirements:',
1510
+ '- Split the UI into sections before writing code.',
1511
+ '- For each section, name concrete components and token variables.',
1512
+ '- Provide final validation notes and required fixes.',
1513
+ ].join('\n');
1514
+ }
1515
+
900
1516
  function resolveDeclByComponent(indexes, component, prefix) {
901
1517
  const byTagOrClass =
902
1518
  pickDecl(indexes, { tagName: component, prefix }) ??
@@ -909,7 +1525,50 @@ function resolveDeclByComponent(indexes, component, prefix) {
909
1525
  };
910
1526
  }
911
1527
 
912
- return findDeclByComponentId(indexes, component);
1528
+ const byComponentId = findDeclByComponentId(indexes, component);
1529
+ if (byComponentId) return byComponentId;
1530
+
1531
+ // Auto-prefix: try with canonical prefix if bare name was given (DIG-15)
1532
+ const comp = typeof component === 'string' ? component.trim().toLowerCase() : '';
1533
+ const p = normalizePrefix(prefix);
1534
+ if (comp && !comp.startsWith(p)) {
1535
+ const prefixed = `${p}-${comp}`;
1536
+ const byPrefixed = pickDecl(indexes, { tagName: prefixed, prefix: p });
1537
+ if (byPrefixed) {
1538
+ const canonicalTag = typeof byPrefixed.tagName === 'string' ? byPrefixed.tagName.toLowerCase() : undefined;
1539
+ return {
1540
+ decl: byPrefixed,
1541
+ modulePath: canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined,
1542
+ };
1543
+ }
1544
+ }
1545
+
1546
+ return undefined;
1547
+ }
1548
+
1549
+ function buildComponentNotFoundError(component, indexes, prefix) {
1550
+ const comp = typeof component === 'string' ? component.trim() : '';
1551
+ const p = normalizePrefix(prefix);
1552
+ const suggestions = [];
1553
+
1554
+ // Try suggesting with prefix
1555
+ if (comp && !comp.toLowerCase().startsWith(p)) {
1556
+ const prefixed = `${p}-${comp.toLowerCase()}`;
1557
+ if (indexes.byTag.has(prefixed)) {
1558
+ suggestions.push(prefixed);
1559
+ }
1560
+ }
1561
+
1562
+ // Levenshtein-based suggestion
1563
+ const suggested = suggestUnknownElementTagName(comp.includes('-') ? comp : `${p}-${comp}`, indexes.byTag);
1564
+ if (suggested && !suggestions.includes(suggested)) {
1565
+ suggestions.push(suggested);
1566
+ }
1567
+
1568
+ const msg = suggestions.length > 0
1569
+ ? `Component not found: ${comp}. Did you mean: ${suggestions.join(', ')}?`
1570
+ : `Component not found: ${comp}`;
1571
+ return { content: [{ type: 'text', text: msg }], isError: true };
913
1572
  }
914
1573
 
915
1574
  // ---------------------------------------------------------------------------
@@ -918,45 +1577,210 @@ function resolveDeclByComponent(indexes, component, prefix) {
918
1577
  //
919
1578
  // loadJsonData(fileName: string) → Promise<object>
920
1579
  // loadValidator() → Promise<{ collectCemCustomElements, validateTextAgainstCem }>
1580
+ // options?: {
1581
+ // plugins?: WcfMcpPlugin[],
1582
+ // loadJsonDataFromPath?: (path: string, fileName: string, pluginName?: string) => Promise<object>
1583
+ // loadTextData?: (fileName: string) => Promise<string>
1584
+ // }
921
1585
  // ---------------------------------------------------------------------------
922
1586
 
923
- export async function createMcpServer(loadJsonData, loadValidator) {
924
- const manifest = await loadJsonData('custom-elements.json');
1587
+ export async function createMcpServer(loadJsonData, loadValidator, options = {}) {
1588
+ const plugins = normalizePlugins(options?.plugins ?? []);
1589
+ const pluginDataSourceMap = buildPluginDataSourceMap(plugins);
1590
+ const loadJsonDataFromPath = typeof options?.loadJsonDataFromPath === 'function'
1591
+ ? options.loadJsonDataFromPath
1592
+ : null;
1593
+ const loadTextData = typeof options?.loadTextData === 'function'
1594
+ ? options.loadTextData
1595
+ : null;
1596
+
1597
+ const loadJson = async (fileName) => {
1598
+ const override = pluginDataSourceMap.get(fileName);
1599
+ if (!override) return loadJsonData(fileName);
1600
+ if (!loadJsonDataFromPath) {
1601
+ throw new Error(`Plugin data source override for ${fileName} requires loadJsonDataFromPath`);
1602
+ }
1603
+ return loadJsonDataFromPath(override.path, fileName, override.pluginName);
1604
+ };
1605
+ const loadText = async (fileName) => {
1606
+ if (!loadTextData) throw new Error(`Text data loader not configured for ${fileName}`);
1607
+ return loadTextData(fileName);
1608
+ };
1609
+
1610
+ const manifest = await loadJson('custom-elements.json');
925
1611
  const indexes = buildIndexes(manifest);
926
1612
  const {
927
1613
  collectCemCustomElements,
928
1614
  validateTextAgainstCem,
929
1615
  detectTokenMisuseInInlineStyles = () => [],
930
1616
  detectAccessibilityMisuseInMarkup = () => [],
1617
+ buildEnumAttributeMap = () => new Map(),
1618
+ detectEnumValueMisuse = () => [],
1619
+ buildSlotNameMap = () => new Map(),
1620
+ detectInvalidSlotName = () => [],
1621
+ detectMissingRequiredAttributes = () => [],
1622
+ detectOrphanedChildComponents = () => [],
1623
+ detectEmptyInteractiveElement = () => [],
931
1624
  } = await loadValidator();
932
1625
  const canonicalCemIndex = collectCemCustomElements(manifest);
933
- const installRegistry = await loadJsonData('install-registry.json');
934
- const patternRegistry = await loadJsonData('pattern-registry.json');
1626
+ const canonicalEnumMap = buildEnumAttributeMap(manifest);
1627
+ const canonicalSlotMap = buildSlotNameMap(manifest);
1628
+ const installRegistry = await loadJson('install-registry.json');
1629
+ const patternRegistry = await loadJson('pattern-registry.json');
935
1630
  const { patterns } = loadPatternRegistryShape(patternRegistry);
936
1631
  const relatedComponentMap = buildRelatedComponentMap(installRegistry, patterns);
937
1632
 
938
1633
  // Load optional data files (design tokens, guidelines index)
939
1634
  let designTokensData = null;
940
1635
  try {
941
- designTokensData = await loadJsonData('design-tokens.json');
1636
+ designTokensData = await loadJson('design-tokens.json');
942
1637
  } catch {
943
1638
  // design-tokens.json may not exist yet
944
1639
  }
945
1640
 
946
1641
  let guidelinesIndexData = null;
947
1642
  try {
948
- guidelinesIndexData = await loadJsonData('guidelines-index.json');
1643
+ guidelinesIndexData = await loadJson('guidelines-index.json');
949
1644
  } catch {
950
1645
  // guidelines-index.json may not exist yet
951
1646
  }
1647
+ let llmsFullText = null;
1648
+ try {
1649
+ llmsFullText = await loadText('llms-full.txt');
1650
+ } catch {
1651
+ // llms-full.txt may not exist in local setup
1652
+ }
952
1653
 
953
1654
  const tokenSuggestionMap = buildTokenSuggestionMap(designTokensData);
954
1655
 
955
1656
  const server = new McpServer({
956
1657
  name: 'web-components-factory-design-system',
957
- version: '0.1.1',
1658
+ version: '0.2.0',
958
1659
  });
959
1660
 
1661
+ server.registerPrompt(
1662
+ FIGMA_TO_WCF_PROMPT,
1663
+ {
1664
+ title: 'Figma To WCF',
1665
+ description:
1666
+ 'Guided prompt for converting a Figma URL into WCF implementation steps with a strict tool order.',
1667
+ argsSchema: {
1668
+ figmaUrl: z.string().trim().url().describe('Figma URL (design or board link)'),
1669
+ userIntent: z.string().optional().describe('Optional implementation intent / screen purpose'),
1670
+ },
1671
+ },
1672
+ async ({ figmaUrl, userIntent }) => ({
1673
+ messages: [{
1674
+ role: 'user',
1675
+ content: {
1676
+ type: 'text',
1677
+ text: buildFigmaToWcfPromptText({ figmaUrl, userIntent }),
1678
+ },
1679
+ }],
1680
+ }),
1681
+ );
1682
+
1683
+ server.registerResource(
1684
+ 'wcf_components',
1685
+ WCF_RESOURCE_URIS.components,
1686
+ {
1687
+ title: 'WCF Component Catalog',
1688
+ description: 'Component catalog snapshot with categories and API entry points.',
1689
+ mimeType: 'application/json',
1690
+ },
1691
+ async () => {
1692
+ const payload = buildComponentsResourcePayload(indexes);
1693
+ return {
1694
+ contents: [{
1695
+ uri: WCF_RESOURCE_URIS.components,
1696
+ mimeType: 'application/json',
1697
+ text: JSON.stringify(payload, null, 2),
1698
+ }],
1699
+ };
1700
+ },
1701
+ );
1702
+
1703
+ server.registerResource(
1704
+ 'wcf_tokens',
1705
+ WCF_RESOURCE_URIS.tokens,
1706
+ {
1707
+ title: 'WCF Design Tokens',
1708
+ description: 'Token summary resource for colors, spacing, typography, radius, and shadows.',
1709
+ mimeType: 'application/json',
1710
+ },
1711
+ async () => {
1712
+ const result = buildTokensResourcePayload(designTokensData);
1713
+ const payload = result.isError ? { error: result.error } : result.payload;
1714
+ return {
1715
+ contents: [{
1716
+ uri: WCF_RESOURCE_URIS.tokens,
1717
+ mimeType: 'application/json',
1718
+ text: JSON.stringify(payload, null, 2),
1719
+ }],
1720
+ };
1721
+ },
1722
+ );
1723
+
1724
+ server.registerResource(
1725
+ 'wcf_guidelines',
1726
+ new ResourceTemplate(WCF_RESOURCE_URIS.guidelinesTemplate, {
1727
+ list: async () => ({
1728
+ resources: GUIDELINE_TOPICS.map((topic) => ({
1729
+ uri: `wcf://guidelines/${topic}`,
1730
+ name: `wcf guidelines (${topic})`,
1731
+ description: `Guideline summary for topic=${topic}`,
1732
+ })),
1733
+ }),
1734
+ complete: {
1735
+ topic: async (value) => {
1736
+ const query = String(value ?? '').trim().toLowerCase();
1737
+ return GUIDELINE_TOPICS.filter((topic) => topic.startsWith(query));
1738
+ },
1739
+ },
1740
+ }),
1741
+ {
1742
+ title: 'WCF Guidelines',
1743
+ description: 'Topic-scoped guideline resource (accessibility|css|patterns|all).',
1744
+ mimeType: 'application/json',
1745
+ },
1746
+ async (_uri, variables) => {
1747
+ const topic = String(variables?.topic ?? '').trim().toLowerCase();
1748
+ const result = buildGuidelinesResourcePayload(guidelinesIndexData, topic);
1749
+ if (result.isError) {
1750
+ throw new Error(`${result.error.code}: ${result.error.message}`);
1751
+ }
1752
+ return {
1753
+ contents: [{
1754
+ uri: `wcf://guidelines/${topic}`,
1755
+ mimeType: 'application/json',
1756
+ text: JSON.stringify(result.payload, null, 2),
1757
+ }],
1758
+ };
1759
+ },
1760
+ );
1761
+
1762
+ server.registerResource(
1763
+ 'wcf_llms_full',
1764
+ WCF_RESOURCE_URIS.llmsFull,
1765
+ {
1766
+ title: 'WCF llms-full',
1767
+ description: 'LLM reference corpus for WCF usage, generated from repository docs.',
1768
+ mimeType: 'text/plain',
1769
+ },
1770
+ async () => {
1771
+ if (typeof llmsFullText !== 'string' || llmsFullText.length === 0) {
1772
+ throw new Error('LLMS_FULL_UNAVAILABLE: llms-full.txt is not available.');
1773
+ }
1774
+ return {
1775
+ contents: [{
1776
+ uri: WCF_RESOURCE_URIS.llmsFull,
1777
+ mimeType: 'text/plain',
1778
+ text: llmsFullText,
1779
+ }],
1780
+ };
1781
+ },
1782
+ );
1783
+
960
1784
  // -----------------------------------------------------------------------
961
1785
  // Tool: get_design_system_overview
962
1786
  // -----------------------------------------------------------------------
@@ -981,13 +1805,44 @@ export async function createMcpServer(loadJsonData, loadValidator) {
981
1805
 
982
1806
  const overview = {
983
1807
  name: 'DADS Web Components (wcf)',
984
- version: '0.1.1',
1808
+ version: '0.2.0',
985
1809
  prefix: CANONICAL_PREFIX,
986
1810
  totalComponents: indexes.decls.length,
987
1811
  componentsByCategory: categoryCount,
988
1812
  totalPatterns: patternList.length,
989
1813
  patterns: patternList,
1814
+ setupInfo: {
1815
+ npmPackage: 'web-components-factory',
1816
+ installCommand: 'npm install web-components-factory',
1817
+ vendorRuntimePath: 'vendor-runtime/',
1818
+ htmlBoilerplate: '<script type="module" src="vendor-runtime/src/autoload.js"></script>',
1819
+ noscriptGuidance: 'WCF components require JavaScript. Provide <noscript> fallback with static HTML equivalents for critical content.',
1820
+ },
990
1821
  ideSetupTemplates: IDE_SETUP_TEMPLATES,
1822
+ availablePrompts: [
1823
+ {
1824
+ name: FIGMA_TO_WCF_PROMPT,
1825
+ purpose: 'Figma-to-WCF conversion workflow prompt',
1826
+ },
1827
+ ],
1828
+ availableResources: [
1829
+ {
1830
+ uri: WCF_RESOURCE_URIS.components,
1831
+ purpose: 'Component catalog snapshot',
1832
+ },
1833
+ {
1834
+ uri: WCF_RESOURCE_URIS.tokens,
1835
+ purpose: 'Token summary snapshot',
1836
+ },
1837
+ {
1838
+ uri: WCF_RESOURCE_URIS.guidelinesTemplate,
1839
+ purpose: 'Topic-based guideline summaries',
1840
+ },
1841
+ {
1842
+ uri: WCF_RESOURCE_URIS.llmsFull,
1843
+ purpose: 'Full LLM reference text for WCF',
1844
+ },
1845
+ ],
991
1846
  availableTools: [
992
1847
  { name: 'get_design_system_overview', purpose: 'This overview (start here)' },
993
1848
  { name: 'list_components', purpose: 'Browse components with progressive disclosure and filters' },
@@ -1000,23 +1855,51 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1000
1855
  { name: 'get_pattern_recipe', purpose: 'Full pattern recipe with dependencies and HTML' },
1001
1856
  { name: 'generate_pattern_snippet', purpose: 'Pattern HTML snippet only' },
1002
1857
  { name: 'get_design_tokens', purpose: 'Query design tokens (colors, spacing, typography, radius, shadows)' },
1858
+ { name: 'get_design_token_detail', purpose: 'Get details, relationships, and usage examples for one token' },
1003
1859
  { name: 'get_accessibility_docs', purpose: 'Search component-level accessibility checklist and WCAG-filtered guidance' },
1004
1860
  { name: 'search_guidelines', purpose: 'Search design system guidelines and best practices' },
1005
1861
  ],
1006
1862
  recommendedWorkflow: [
1007
1863
  '1. get_design_system_overview → understand components, patterns, tokens, and IDE setup templates',
1008
- '2. search_guidelinesfind relevant guidelines',
1009
- '3. get_design_tokensget correct token values',
1010
- '4. get_accessibility_docsfetch component-level accessibility checklist',
1011
- '5. list_components (category/query + pagination) shortlist components',
1012
- '6. search_icons (optional) find icon names quickly',
1013
- '7. get_component_apicheck attributes, slots, events, CSS parts',
1014
- '8. generate_usage_snippet or get_pattern_recipeget code',
1015
- '9. validate_markupverify your HTML and use suggestions to self-correct',
1016
- '10. get_install_recipeget import/install instructions',
1864
+ '2. figma_to_wcf (optional) bootstrap the Figma-to-WCF tool sequence',
1865
+ '3. wcf://components and wcf://tokens resources preload catalog/token context',
1866
+ '4. search_guidelinesfind relevant guidelines',
1867
+ '5. get_design_tokens get correct token values',
1868
+ '6. get_design_token_detailinspect one token with references/referencedBy and usage examples',
1869
+ '7. get_accessibility_docsfetch component-level accessibility checklist',
1870
+ '8. list_components (category/query + pagination) shortlist components',
1871
+ '9. search_icons (optional) find icon names quickly',
1872
+ '10. get_component_apicheck attributes, slots, events, CSS parts',
1873
+ '11. generate_usage_snippet or get_pattern_recipe → get code',
1874
+ '12. validate_markup → verify your HTML and use suggestions to self-correct',
1875
+ '13. get_install_recipe → get import/install instructions',
1017
1876
  ],
1877
+ experimental: {
1878
+ plugins: {
1879
+ enabled: plugins.length > 0,
1880
+ note: PLUGIN_TOOL_NOTICE,
1881
+ pluginCount: plugins.length,
1882
+ pluginToolCount: plugins.reduce((sum, plugin) => sum + (plugin.tools?.length ?? 0), 0),
1883
+ plugins: plugins.map((plugin) => ({
1884
+ name: plugin.name,
1885
+ version: plugin.version,
1886
+ toolCount: plugin.tools?.length ?? 0,
1887
+ dataSourceOverrides: plugin.dataSources?.map((source) => source.fileName) ?? [],
1888
+ })),
1889
+ },
1890
+ },
1018
1891
  };
1019
1892
 
1893
+ for (const plugin of plugins) {
1894
+ const tools = Array.isArray(plugin.tools) ? plugin.tools : [];
1895
+ for (const tool of tools) {
1896
+ overview.availableTools.push({
1897
+ name: tool.name,
1898
+ purpose: `${tool.description} (plugin: ${plugin.name})`,
1899
+ });
1900
+ }
1901
+ }
1902
+
1020
1903
  return {
1021
1904
  content: [{ type: 'text', text: JSON.stringify(overview, null, 2) }],
1022
1905
  };
@@ -1030,22 +1913,30 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1030
1913
  'list_components',
1031
1914
  {
1032
1915
  description:
1033
- 'List custom elements in the design system. When: exploring available components, searching by keyword, or paging through results. Returns: array of {tagName, className, description, category}. After: use get_component_api for details on a specific component.',
1916
+ 'List custom elements in the design system. When: exploring available components, searching by keyword, or paging through results. Returns: {items, total, limit, offset, hasMore} where items is array of {tagName, className, description, category}. After: use get_component_api for details on a specific component.',
1034
1917
  inputSchema: {
1035
1918
  category: z
1036
1919
  .enum(['Form', 'Actions', 'Navigation', 'Content', 'Display', 'Layout', 'Other'])
1037
1920
  .optional()
1038
1921
  .describe('Filter by component category'),
1039
1922
  query: z.string().optional().describe('Search by tagName/className/description/category/modulePath'),
1040
- limit: z.number().int().min(1).max(200).optional().describe('Maximum items to return (optional; omit for all results)'),
1923
+ limit: z.number().int().min(1).max(200).optional().describe('Maximum items to return (default: 20; set 200 for all results)'),
1041
1924
  offset: z.number().int().min(0).optional().describe('Pagination offset (default: 0)'),
1042
1925
  prefix: z.string().optional(),
1043
1926
  },
1044
1927
  },
1045
1928
  async ({ category, query, limit, offset, prefix }) => {
1046
- const { items } = buildComponentSummaries(indexes, { category, query, limit, offset, prefix });
1929
+ const page = buildComponentSummaries(indexes, { category, query, limit, offset, prefix });
1930
+ const payload = {
1931
+ items: page.items,
1932
+ total: page.total,
1933
+ limit: page.limit,
1934
+ offset: page.offset,
1935
+ hasMore: page.hasMore,
1936
+ };
1937
+ if (page._notice) payload._notice = page._notice;
1047
1938
  return {
1048
- content: [{ type: 'text', text: JSON.stringify(items, null, 2) }],
1939
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1049
1940
  };
1050
1941
  },
1051
1942
  );
@@ -1082,27 +1973,34 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1082
1973
  description:
1083
1974
  'Get the full API surface of a single component (attributes, slots, events, CSS parts, CSS custom properties). When: you need detailed specs for a component. Returns: complete component specification. After: use generate_usage_snippet for a code example.',
1084
1975
  inputSchema: {
1085
- tagName: z.string().optional(),
1086
- className: z.string().optional(),
1976
+ tagName: z.string().optional().describe('Tag name (e.g., "dads-button")'),
1977
+ className: z.string().optional().describe('Class name (e.g., "DadsButton")'),
1978
+ component: z.string().optional().describe('Any identifier: tagName, className, or bare name (e.g., "button")'),
1087
1979
  prefix: z.string().optional(),
1088
1980
  },
1089
1981
  },
1090
- async ({ tagName, className, prefix }) => {
1091
- const decl = pickDecl(indexes, { tagName, className, prefix });
1982
+ async ({ tagName, className, component, prefix }) => {
1983
+ const p = normalizePrefix(prefix);
1984
+ let decl;
1985
+ let modulePath;
1986
+
1987
+ if (component) {
1988
+ const resolved = resolveDeclByComponent(indexes, component, p);
1989
+ decl = resolved?.decl;
1990
+ modulePath = resolved?.modulePath;
1991
+ } else {
1992
+ decl = pickDecl(indexes, { tagName, className, prefix: p });
1993
+ }
1994
+
1092
1995
  if (!decl) {
1093
- return {
1094
- content: [
1095
- {
1096
- type: 'text',
1097
- text: `Component not found (tagName=${String(tagName ?? '')}, className=${String(className ?? '')})`,
1098
- },
1099
- ],
1100
- isError: true,
1101
- };
1996
+ const identifier = component || tagName || className || '';
1997
+ return buildComponentNotFoundError(identifier, indexes, p);
1102
1998
  }
1103
1999
 
1104
2000
  const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
1105
- const modulePath = canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined;
2001
+ if (!modulePath) {
2002
+ modulePath = canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined;
2003
+ }
1106
2004
  const api = serializeApi(decl, modulePath, prefix);
1107
2005
  const relatedComponents = getRelatedComponentsForTag({
1108
2006
  canonicalTagName: canonicalTag,
@@ -1136,19 +2034,16 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1136
2034
  },
1137
2035
  },
1138
2036
  async ({ component, prefix }) => {
1139
- const decl =
1140
- pickDecl(indexes, { tagName: component, prefix }) ??
1141
- pickDecl(indexes, { className: component, prefix });
2037
+ const p = normalizePrefix(prefix);
2038
+ const resolved = resolveDeclByComponent(indexes, component, p);
2039
+ const decl = resolved?.decl;
1142
2040
 
1143
2041
  if (!decl) {
1144
- return {
1145
- content: [{ type: 'text', text: `Component not found: ${component}` }],
1146
- isError: true,
1147
- };
2042
+ return buildComponentNotFoundError(component, indexes, p);
1148
2043
  }
1149
2044
 
1150
2045
  const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
1151
- const modulePath = canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined;
2046
+ const modulePath = resolved?.modulePath ?? (canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined);
1152
2047
  const api = serializeApi(decl, modulePath, prefix);
1153
2048
  const snippet = generateSnippet(api, prefix);
1154
2049
 
@@ -1206,6 +2101,11 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1206
2101
  const deps = Array.isArray(install.deps) ? install.deps : [];
1207
2102
  const tags = Array.isArray(install.tags) ? install.tags : [];
1208
2103
 
2104
+ // Resolve transitive dependencies via BFS
2105
+ const transitiveDeps = componentId
2106
+ ? resolveComponentClosure({ installRegistry }, [componentId]).filter((id) => id !== componentId)
2107
+ : [];
2108
+
1209
2109
  const tagNames =
1210
2110
  tags.length > 0 ? tags.map((t) => withPrefix(String(t).toLowerCase(), p)) : [api.tagName];
1211
2111
 
@@ -1228,6 +2128,7 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1228
2128
  componentId,
1229
2129
  tagNames,
1230
2130
  deps,
2131
+ transitiveDeps,
1231
2132
  define,
1232
2133
  defineHint,
1233
2134
  source: install.source,
@@ -1250,7 +2151,7 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1250
2151
  'validate_markup',
1251
2152
  {
1252
2153
  description:
1253
- 'Validate HTML against the design system Custom Elements Manifest. When: checking generated or written HTML for correctness. Returns: diagnostics array with errors (unknown elements), warnings (unknown attributes/token misuse/accessibility misuse), and optional suggestion text for quick recovery. Use after generating HTML to catch mistakes.',
2154
+ 'Validate HTML against the design system Custom Elements Manifest. When: checking generated or written HTML for correctness. Returns: diagnostics array with errors (unknown elements/invalid enum values/invalid slot names/missing required attributes), warnings (unknown attributes/token misuse/accessibility misuse/orphaned children/empty interactive elements), and optional suggestion text for quick recovery. Use after generating HTML to catch mistakes.',
1254
2155
  inputSchema: {
1255
2156
  html: z.string(),
1256
2157
  prefix: z.string().optional(),
@@ -1259,11 +2160,12 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1259
2160
  async ({ html, prefix }) => {
1260
2161
  const p = normalizePrefix(prefix);
1261
2162
  let cemIndex = canonicalCemIndex;
2163
+ let enumMap = canonicalEnumMap;
2164
+ let slotMap = canonicalSlotMap;
1262
2165
  if (p !== CANONICAL_PREFIX) {
1263
- const combined = new Map(canonicalCemIndex);
1264
- const prefixed = applyPrefixToCemIndex(canonicalCemIndex, p);
1265
- for (const [tag, meta] of prefixed.entries()) combined.set(tag, meta);
1266
- cemIndex = combined;
2166
+ cemIndex = mergeWithPrefixed(canonicalCemIndex, p);
2167
+ enumMap = mergeWithPrefixed(canonicalEnumMap, p);
2168
+ slotMap = mergeWithPrefixed(canonicalSlotMap, p);
1267
2169
  }
1268
2170
 
1269
2171
  const cemDiagnostics = validateTextAgainstCem({
@@ -1276,6 +2178,13 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1276
2178
  },
1277
2179
  });
1278
2180
 
2181
+ const enumDiagnostics = detectEnumValueMisuse({
2182
+ filePath: '<markup>',
2183
+ text: html,
2184
+ enumMap,
2185
+ severity: 'error',
2186
+ });
2187
+
1279
2188
  const tokenMisuseDiagnostics = detectTokenMisuseInInlineStyles({
1280
2189
  filePath: '<markup>',
1281
2190
  text: html,
@@ -1289,7 +2198,35 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1289
2198
  severity: 'warning',
1290
2199
  });
1291
2200
 
1292
- const diagnostics = [...cemDiagnostics, ...tokenMisuseDiagnostics, ...accessibilityDiagnostics].map((d) => {
2201
+ const slotDiagnostics = detectInvalidSlotName({
2202
+ filePath: '<markup>',
2203
+ text: html,
2204
+ slotMap,
2205
+ severity: 'error',
2206
+ });
2207
+
2208
+ const requiredAttrDiagnostics = detectMissingRequiredAttributes({
2209
+ filePath: '<markup>',
2210
+ text: html,
2211
+ prefix: p,
2212
+ severity: 'error',
2213
+ });
2214
+
2215
+ const orphanDiagnostics = detectOrphanedChildComponents({
2216
+ filePath: '<markup>',
2217
+ text: html,
2218
+ prefix: p,
2219
+ severity: 'warning',
2220
+ });
2221
+
2222
+ const emptyInteractiveDiagnostics = detectEmptyInteractiveElement({
2223
+ filePath: '<markup>',
2224
+ text: html,
2225
+ prefix: p,
2226
+ severity: 'warning',
2227
+ });
2228
+
2229
+ const diagnostics = [...cemDiagnostics, ...enumDiagnostics, ...slotDiagnostics, ...requiredAttrDiagnostics, ...orphanDiagnostics, ...emptyInteractiveDiagnostics, ...tokenMisuseDiagnostics, ...accessibilityDiagnostics].map((d) => {
1293
2230
  const suggestion = buildDiagnosticSuggestion({ diagnostic: d, cemIndex });
1294
2231
  return {
1295
2232
  file: d.file,
@@ -1454,35 +2391,48 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1454
2391
  .describe('Filter by token category'),
1455
2392
  query: z.string().optional()
1456
2393
  .describe('Search token names (partial match)'),
2394
+ theme: z.enum(['light', 'dark', 'all']).optional()
2395
+ .describe('Theme filter (currently light only; dark/all return an error due to NG-06)'),
1457
2396
  },
1458
2397
  },
1459
- async ({ type, category, query }) => {
1460
- if (!designTokensData) {
2398
+ async ({ type, category, query, theme }) => {
2399
+ const { isError, payload } = buildDesignTokensPayload(designTokensData, { type, category, query, theme });
2400
+ if (isError) {
1461
2401
  return {
1462
- content: [{ type: 'text', text: 'Design tokens data not available. Run: npm run mcp:extract-tokens' }],
2402
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1463
2403
  isError: true,
1464
2404
  };
1465
2405
  }
2406
+ return buildJsonToolResponse(payload);
2407
+ },
2408
+ );
1466
2409
 
1467
- let tokens = Array.isArray(designTokensData.tokens) ? designTokensData.tokens : [];
1468
-
1469
- if (type) {
1470
- tokens = tokens.filter((t) => t.type === type);
1471
- }
1472
- if (category) {
1473
- tokens = tokens.filter((t) => t.category === category);
1474
- }
1475
- if (query) {
1476
- const q = query.toLowerCase();
1477
- tokens = tokens.filter((t) => t.name.toLowerCase().includes(q));
2410
+ // -----------------------------------------------------------------------
2411
+ // Tool: get_design_token_detail
2412
+ // -----------------------------------------------------------------------
2413
+ server.registerTool(
2414
+ 'get_design_token_detail',
2415
+ {
2416
+ description:
2417
+ 'Get details for one design token. ' +
2418
+ 'When: you already found a token and need its references, referencedBy, and usage examples. ' +
2419
+ 'Returns: token detail object with relationships and example CSS snippets. ' +
2420
+ 'After: apply the cssVariable in your implementation or validate related semantic aliases.',
2421
+ inputSchema: {
2422
+ name: z.string()
2423
+ .describe('Token name or css variable (e.g. --color-primary or var(--color-primary))'),
2424
+ theme: z.enum(['light', 'dark', 'all']).optional()
2425
+ .describe('Theme selector (currently only light is supported due to NG-06)'),
2426
+ },
2427
+ },
2428
+ async ({ name, theme }) => {
2429
+ const { isError, payload } = buildDesignTokenDetailPayload(designTokensData, name, theme);
2430
+ if (isError) {
2431
+ return {
2432
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
2433
+ isError: true,
2434
+ };
1478
2435
  }
1479
-
1480
- const payload = {
1481
- total: tokens.length,
1482
- tokens,
1483
- summary: designTokensData.summary,
1484
- };
1485
-
1486
2436
  return buildJsonToolResponse(payload);
1487
2437
  },
1488
2438
  );
@@ -1582,6 +2532,7 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1582
2532
  const max = maxResults ?? 5;
1583
2533
  const documents = Array.isArray(guidelinesIndexData.documents) ? guidelinesIndexData.documents : [];
1584
2534
  const q = query.toLowerCase();
2535
+ const expandedTerms = expandQueryWithSynonyms(q);
1585
2536
 
1586
2537
  // Score and rank sections
1587
2538
  const results = [];
@@ -1595,6 +2546,7 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1595
2546
  const heading = String(section.heading ?? '').toLowerCase();
1596
2547
  const keywords = Array.isArray(section.keywords) ? section.keywords : [];
1597
2548
  const snippet = String(section.snippet ?? '').toLowerCase();
2549
+ const body = String(section.body ?? '').toLowerCase();
1598
2550
 
1599
2551
  // Heading match: weight 3
1600
2552
  if (heading.includes(q)) score += 3;
@@ -1610,6 +2562,27 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1610
2562
  // Snippet match: weight 1
1611
2563
  if (snippet.includes(q)) score += 1;
1612
2564
 
2565
+ // Body text match: weight 1
2566
+ if (body && body.includes(q)) score += 1;
2567
+
2568
+ // Synonym expansion match: weight 1 (only for expanded terms, not the original)
2569
+ if (score === 0 && expandedTerms.length > 1) {
2570
+ for (let i = 1; i < expandedTerms.length; i++) {
2571
+ const syn = expandedTerms[i];
2572
+ if (heading.includes(syn) || snippet.includes(syn) || body.includes(syn)) {
2573
+ score += 1;
2574
+ break;
2575
+ }
2576
+ for (const kw of keywords) {
2577
+ if (String(kw).toLowerCase().includes(syn)) {
2578
+ score += 1;
2579
+ break;
2580
+ }
2581
+ }
2582
+ if (score > 0) break;
2583
+ }
2584
+ }
2585
+
1613
2586
  if (score > 0) {
1614
2587
  results.push({
1615
2588
  score,
@@ -1635,9 +2608,81 @@ export async function createMcpServer(loadJsonData, loadValidator) {
1635
2608
  results: topResults,
1636
2609
  };
1637
2610
 
2611
+ // Zero-result fallback: suggest alternative queries and tools
2612
+ if (results.length === 0) {
2613
+ const synonymExpansions = expandedTerms.filter((t) => t !== q);
2614
+ payload.suggestions = {
2615
+ alternativeQueries: synonymExpansions.length > 0 ? synonymExpansions : [],
2616
+ alternativeTools: [
2617
+ { tool: 'get_accessibility_docs', hint: 'For component-specific a11y checks' },
2618
+ { tool: 'get_component_api', hint: 'For component API details' },
2619
+ ],
2620
+ };
2621
+ }
2622
+
1638
2623
  return buildJsonToolResponse(payload);
1639
2624
  },
1640
2625
  );
1641
2626
 
1642
- return { server };
2627
+ for (const plugin of plugins) {
2628
+ const pluginTools = Array.isArray(plugin.tools) ? plugin.tools : [];
2629
+ for (const tool of pluginTools) {
2630
+ server.registerTool(
2631
+ tool.name,
2632
+ {
2633
+ description: tool.description,
2634
+ inputSchema: tool.inputSchema ?? {},
2635
+ },
2636
+ async (args) => {
2637
+ try {
2638
+ if (typeof tool.handler === 'function') {
2639
+ const result = await tool.handler(args, {
2640
+ plugin: { name: plugin.name, version: plugin.version },
2641
+ helpers: {
2642
+ loadJsonData: loadJson,
2643
+ buildJsonToolResponse,
2644
+ normalizePrefix,
2645
+ withPrefix,
2646
+ toCanonicalTagName,
2647
+ },
2648
+ });
2649
+ if (isPlainObject(result) && Array.isArray(result.content)) {
2650
+ return result;
2651
+ }
2652
+ return buildJsonToolResponse(result ?? {});
2653
+ }
2654
+ return buildJsonToolResponse(tool.staticPayload ?? {});
2655
+ } catch (error) {
2656
+ const message = error instanceof Error ? error.message : String(error);
2657
+ return {
2658
+ content: [{
2659
+ type: 'text',
2660
+ text: JSON.stringify({
2661
+ error: {
2662
+ code: 'PLUGIN_TOOL_RUNTIME_ERROR',
2663
+ message: `Plugin tool failed (${tool.name}): ${message}`,
2664
+ plugin: plugin.name,
2665
+ },
2666
+ }, null, 2),
2667
+ }],
2668
+ isError: true,
2669
+ };
2670
+ }
2671
+ },
2672
+ );
2673
+ }
2674
+ }
2675
+
2676
+ return {
2677
+ server,
2678
+ pluginRuntime: {
2679
+ pluginCount: plugins.length,
2680
+ pluginToolCount: plugins.reduce((sum, plugin) => sum + (plugin.tools?.length ?? 0), 0),
2681
+ dataSourceOverrides: [...pluginDataSourceMap.entries()].map(([fileName, item]) => ({
2682
+ fileName,
2683
+ path: item.path,
2684
+ pluginName: item.pluginName,
2685
+ })),
2686
+ },
2687
+ };
1643
2688
  }