@monoharada/wcf-mcp 0.1.1 → 0.1.2

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
@@ -14,6 +14,9 @@ import { z } from 'zod';
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
16
  export const CANONICAL_PREFIX = 'dads';
17
+ export const MAX_PREFIX_LENGTH = 64;
18
+ export const STRUCTURED_CONTENT_DISABLE_FLAG = 'WCF_MCP_DISABLE_STRUCTURED_CONTENT';
19
+ export const MAX_TOOL_RESULT_BYTES = 100 * 1024;
17
20
 
18
21
  export const CATEGORY_MAP = {
19
22
  'dads-input-text': 'Form',
@@ -79,6 +82,128 @@ export const CATEGORY_MAP = {
79
82
  'dads-loading-icon': 'Display',
80
83
  };
81
84
 
85
+ const TOKEN_MISUSE_ALLOWED_TYPES = Object.freeze(new Set(['color', 'spacing']));
86
+ const STRUCTURED_CONTENT_DISABLE_TRUE_VALUES = Object.freeze(new Set(['1', 'true', 'yes', 'on']));
87
+ const WCAG_LEVELS = Object.freeze(new Set(['A', 'AA', 'AAA', 'all']));
88
+ const A11Y_CATEGORY_LEVEL_MAP = Object.freeze({
89
+ semantics: 'A',
90
+ keyboard: 'A',
91
+ labels: 'A',
92
+ states: 'AA',
93
+ zoom: 'AA',
94
+ motion: 'AA',
95
+ callouts: 'AA',
96
+ guideline: 'A',
97
+ });
98
+ const NPX_TEMPLATE = Object.freeze({
99
+ command: 'npx',
100
+ args: ['@monoharada/wcf-mcp'],
101
+ });
102
+ export const IDE_SETUP_TEMPLATES = Object.freeze([
103
+ {
104
+ ide: 'Claude Desktop',
105
+ configPath: 'claude_desktop_config.json',
106
+ snippet: {
107
+ mcpServers: {
108
+ wcf: NPX_TEMPLATE,
109
+ },
110
+ },
111
+ },
112
+ {
113
+ ide: 'Claude Code',
114
+ configPath: '.mcp.json',
115
+ snippet: {
116
+ mcpServers: {
117
+ wcf: NPX_TEMPLATE,
118
+ },
119
+ },
120
+ },
121
+ {
122
+ ide: 'Cursor',
123
+ configPath: '.cursor/mcp.json',
124
+ snippet: {
125
+ mcpServers: {
126
+ wcf: NPX_TEMPLATE,
127
+ },
128
+ },
129
+ },
130
+ ]);
131
+
132
+ export function isStructuredContentDisabled(env = process.env) {
133
+ const raw = String(env?.[STRUCTURED_CONTENT_DISABLE_FLAG] ?? '').trim().toLowerCase();
134
+ return STRUCTURED_CONTENT_DISABLE_TRUE_VALUES.has(raw);
135
+ }
136
+
137
+ export function toStructuredContent(data) {
138
+ return {
139
+ type: 'application/json',
140
+ data,
141
+ };
142
+ }
143
+
144
+ export function measureToolResultBytes(result) {
145
+ return Buffer.byteLength(JSON.stringify(result), 'utf8');
146
+ }
147
+
148
+ export function buildJsonToolResponse(payload, { env = process.env } = {}) {
149
+ const content = [{
150
+ type: 'text',
151
+ text: JSON.stringify(payload, null, 2),
152
+ }];
153
+
154
+ if (isStructuredContentDisabled(env)) {
155
+ return { content };
156
+ }
157
+
158
+ const withStructuredContent = {
159
+ content,
160
+ structuredContent: toStructuredContent(payload),
161
+ };
162
+
163
+ // Keep response size under the 100KB guardrail even when structuredContent is enabled.
164
+ if (measureToolResultBytes(withStructuredContent) > MAX_TOOL_RESULT_BYTES) {
165
+ return { content };
166
+ }
167
+
168
+ return withStructuredContent;
169
+ }
170
+
171
+ export function normalizeTokenValue(value) {
172
+ if (typeof value === 'string') return value.trim().toLowerCase().replace(/\s+/g, ' ');
173
+ if (typeof value === 'number') return String(value);
174
+ return '';
175
+ }
176
+
177
+ export function normalizeCssVariable(value) {
178
+ if (typeof value !== 'string') return '';
179
+
180
+ const raw = value.trim();
181
+ if (!raw) return '';
182
+ if (raw.startsWith('--')) return raw;
183
+
184
+ const varMatch = /^var\(\s*(--[^,\s)]+)\s*(?:,\s*[^)]+)?\)$/.exec(raw);
185
+ if (varMatch) return varMatch[1];
186
+
187
+ return '';
188
+ }
189
+
190
+ export function buildTokenSuggestionMap(designTokensData) {
191
+ if (!Array.isArray(designTokensData?.tokens)) return new Map();
192
+
193
+ const out = new Map();
194
+ for (const token of designTokensData.tokens) {
195
+ const type = String(token?.type ?? '').toLowerCase();
196
+ if (!TOKEN_MISUSE_ALLOWED_TYPES.has(type)) continue;
197
+
198
+ const cssVariable = normalizeCssVariable(token?.cssVariable);
199
+ if (!cssVariable) continue;
200
+
201
+ const normalized = normalizeTokenValue(token?.value);
202
+ if (normalized && !out.has(normalized)) out.set(normalized, cssVariable);
203
+ }
204
+ return out;
205
+ }
206
+
82
207
  // ---------------------------------------------------------------------------
83
208
  // Helpers (exported for testing)
84
209
  // ---------------------------------------------------------------------------
@@ -87,11 +212,15 @@ export function getCategory(tagName) {
87
212
  return CATEGORY_MAP[tagName] ?? 'Other';
88
213
  }
89
214
 
90
- export function normalizePrefix(prefix) {
215
+ function normalizePrefixRaw(prefix) {
91
216
  if (typeof prefix !== 'string' || prefix.trim() === '') return CANONICAL_PREFIX;
92
217
  return prefix.trim().toLowerCase();
93
218
  }
94
219
 
220
+ export function normalizePrefix(prefix) {
221
+ return normalizePrefixRaw(prefix).slice(0, MAX_PREFIX_LENGTH);
222
+ }
223
+
95
224
  export function withPrefix(tagName, prefix) {
96
225
  if (typeof tagName !== 'string') return tagName;
97
226
  const p = normalizePrefix(prefix);
@@ -107,14 +236,89 @@ export function toCanonicalTagName(tagName, prefix) {
107
236
  if (!raw) return undefined;
108
237
  if (raw.startsWith(`${CANONICAL_PREFIX}-`)) return raw;
109
238
 
110
- const p = normalizePrefix(prefix);
111
- if (p !== CANONICAL_PREFIX && raw.startsWith(`${p}-`)) {
112
- return `${CANONICAL_PREFIX}-${raw.slice(p.length + 1)}`;
239
+ const candidates = [...new Set([normalizePrefix(prefix), normalizePrefixRaw(prefix)])];
240
+ for (const p of candidates) {
241
+ if (p !== CANONICAL_PREFIX && raw.startsWith(`${p}-`)) {
242
+ return `${CANONICAL_PREFIX}-${raw.slice(p.length + 1)}`;
243
+ }
113
244
  }
114
245
 
115
246
  return raw;
116
247
  }
117
248
 
249
+ export function levenshteinDistance(left, right) {
250
+ const a = String(left ?? '');
251
+ const b = String(right ?? '');
252
+ if (a === b) return 0;
253
+ if (!a.length) return b.length;
254
+ if (!b.length) return a.length;
255
+
256
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
257
+ const curr = Array.from({ length: b.length + 1 }, () => 0);
258
+
259
+ for (let i = 1; i <= a.length; i += 1) {
260
+ curr[0] = i;
261
+ for (let j = 1; j <= b.length; j += 1) {
262
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
263
+ curr[j] = Math.min(
264
+ curr[j - 1] + 1,
265
+ prev[j] + 1,
266
+ prev[j - 1] + cost,
267
+ );
268
+ }
269
+ for (let j = 0; j <= b.length; j += 1) prev[j] = curr[j];
270
+ }
271
+
272
+ return prev[b.length];
273
+ }
274
+
275
+ export function suggestUnknownElementTagName(tagName, cemIndex) {
276
+ const target = String(tagName ?? '').trim().toLowerCase();
277
+ if (!target || !target.includes('-')) return undefined;
278
+
279
+ let bestTag;
280
+ let bestDistance = Number.POSITIVE_INFINITY;
281
+ const candidateSource = cemIndex instanceof Map ? cemIndex.keys() : [];
282
+ for (const rawCandidate of candidateSource) {
283
+ const candidate = String(rawCandidate ?? '').toLowerCase();
284
+ if (!candidate || !candidate.includes('-') || candidate === target) continue;
285
+ const distance = levenshteinDistance(target, candidate);
286
+ if (distance < bestDistance) {
287
+ bestDistance = distance;
288
+ bestTag = candidate;
289
+ }
290
+ }
291
+
292
+ if (!bestTag) return undefined;
293
+ const maxDistance = Math.max(1, Math.ceil(target.length * 0.3));
294
+ if (bestDistance > maxDistance) return undefined;
295
+ return bestTag;
296
+ }
297
+
298
+ export function buildDiagnosticSuggestion({ diagnostic, cemIndex }) {
299
+ const code = String(diagnostic?.code ?? '');
300
+ if (!code) return undefined;
301
+
302
+ if (code === 'unknownElement') {
303
+ const tagName = suggestUnknownElementTagName(diagnostic?.tagName, cemIndex);
304
+ return tagName ? `Did you mean "${tagName}"?` : undefined;
305
+ }
306
+
307
+ if (code === 'forbiddenAttribute' && String(diagnostic?.attrName ?? '').toLowerCase() === 'placeholder') {
308
+ return 'Use aria-label or aria-describedby support text instead of placeholder.';
309
+ }
310
+
311
+ if (code === 'ariaLiveNotRecommended') {
312
+ return 'Remove aria-live and connect support or error text via aria-describedby.';
313
+ }
314
+
315
+ if (code === 'roleAlertNotRecommended') {
316
+ return 'Use role="alert" only for urgent live updates; otherwise use static text associated via aria-describedby.';
317
+ }
318
+
319
+ return undefined;
320
+ }
321
+
118
322
  export function findCustomElementDeclarations(manifest) {
119
323
  const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
120
324
  const decls = [];
@@ -327,6 +531,387 @@ export function resolveComponentClosure({ installRegistry }, componentIds) {
327
531
  return [...out];
328
532
  }
329
533
 
534
+ export function buildComponentSummaries(indexes, { category, query, limit, offset, prefix } = {}) {
535
+ const p = normalizePrefix(prefix);
536
+ 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;
538
+ const pageOffset = Number.isInteger(offset) ? Math.max(0, offset) : 0;
539
+
540
+ let items = indexes.decls.map(({ decl, tagName, modulePath }) => ({
541
+ tagName: withPrefix(tagName, p),
542
+ className: typeof decl?.name === 'string' ? decl.name : undefined,
543
+ description: typeof decl?.description === 'string' ? decl.description : undefined,
544
+ category: getCategory(tagName),
545
+ modulePath,
546
+ }));
547
+
548
+ if (category) {
549
+ items = items.filter((item) => item.category === category);
550
+ }
551
+
552
+ if (q) {
553
+ items = items.filter((item) => {
554
+ const haystacks = [
555
+ item.tagName,
556
+ item.className,
557
+ item.description,
558
+ item.category,
559
+ item.modulePath,
560
+ ];
561
+ return haystacks.some((value) => String(value ?? '').toLowerCase().includes(q));
562
+ });
563
+ }
564
+
565
+ const total = items.length;
566
+ const paged = items.slice(pageOffset, pageOffset + pageSize);
567
+
568
+ return {
569
+ total,
570
+ limit: pageSize,
571
+ offset: pageOffset,
572
+ hasMore: pageOffset + paged.length < total,
573
+ items: paged,
574
+ };
575
+ }
576
+
577
+ export function parseIconNamesFromDescription(description) {
578
+ if (typeof description !== 'string' || description.trim() === '') return [];
579
+
580
+ const markerMatch = description.match(/iconPathsのキー[::]\s*([^))\n]+)/u);
581
+ if (!markerMatch) return [];
582
+
583
+ return [...new Set(
584
+ markerMatch[1]
585
+ .split(/[,、]/)
586
+ .map((name) => name.trim())
587
+ .map((name) => name.replace(/[`'"]/g, ''))
588
+ .filter(Boolean),
589
+ )];
590
+ }
591
+
592
+ export function parseIconNamesFromType(typeText) {
593
+ if (typeof typeText !== 'string' || typeText.trim() === '') return [];
594
+ const out = [];
595
+ const regex = /'([^']+)'|"([^"]+)"|`([^`]+)`/g;
596
+ let match;
597
+ while ((match = regex.exec(typeText)) !== null) {
598
+ const value = match[1] ?? match[2] ?? match[3];
599
+ if (typeof value === 'string' && value.trim() !== '') out.push(value.trim());
600
+ }
601
+ return [...new Set(out)];
602
+ }
603
+
604
+ export function extractIconNames(indexes) {
605
+ const decl = indexes.byTag.get('dads-icon');
606
+ if (!decl) return [];
607
+
608
+ const attributes = Array.isArray(decl?.attributes) ? decl.attributes : [];
609
+ const nameAttr = attributes.find((attr) => String(attr?.name ?? '') === 'name');
610
+ if (!nameAttr) return [];
611
+
612
+ const fromDescription = parseIconNamesFromDescription(nameAttr?.description);
613
+ const fromType = parseIconNamesFromType(nameAttr?.type?.text);
614
+
615
+ return [...new Set([...fromDescription, ...fromType])];
616
+ }
617
+
618
+ export function buildIconCatalog(indexes, prefix) {
619
+ const p = normalizePrefix(prefix);
620
+ const tag = withPrefix('dads-icon', p);
621
+ const names = extractIconNames(indexes).sort((left, right) => left.localeCompare(right));
622
+
623
+ return names.map((name) => ({
624
+ name,
625
+ variants: ['default'],
626
+ usageExample: `<${tag} name="${name}" size="20"></${tag}>`,
627
+ }));
628
+ }
629
+
630
+ export function searchIconCatalog(indexes, { query, limit, offset, prefix } = {}) {
631
+ const q = typeof query === 'string' ? query.trim().toLowerCase() : '';
632
+ const pageSize = Number.isInteger(limit) ? Math.max(1, Math.min(limit, 100)) : 20;
633
+ const pageOffset = Number.isInteger(offset) ? Math.max(0, offset) : 0;
634
+
635
+ let icons = buildIconCatalog(indexes, prefix);
636
+ if (q) {
637
+ icons = icons.filter((icon) => icon.name.toLowerCase().includes(q));
638
+ }
639
+
640
+ const total = icons.length;
641
+ const paged = icons.slice(pageOffset, pageOffset + pageSize);
642
+
643
+ return {
644
+ total,
645
+ limit: pageSize,
646
+ offset: pageOffset,
647
+ hasMore: pageOffset + paged.length < total,
648
+ icons: paged,
649
+ };
650
+ }
651
+
652
+ export function buildRelatedComponentMap(installRegistry, patterns) {
653
+ const components = installRegistry?.components && typeof installRegistry.components === 'object' ? installRegistry.components : {};
654
+ const patternList = Object.values(patterns ?? {});
655
+ const related = new Map();
656
+
657
+ const addRelation = (fromId, toId, via) => {
658
+ const from = String(fromId ?? '').trim();
659
+ const to = String(toId ?? '').trim();
660
+ if (!from || !to || from === to) return;
661
+
662
+ if (!related.has(from)) related.set(from, new Map());
663
+ const relMap = related.get(from);
664
+ if (!relMap.has(to)) relMap.set(to, new Set());
665
+ relMap.get(to).add(via);
666
+ };
667
+
668
+ for (const pattern of patternList) {
669
+ const patternId = String(pattern?.id ?? '').trim() || 'pattern';
670
+ const requires = [...new Set((Array.isArray(pattern?.requires) ? pattern.requires : []).map((id) => String(id ?? '').trim()).filter(Boolean))];
671
+
672
+ for (const fromId of requires) {
673
+ for (const toId of requires) {
674
+ addRelation(fromId, toId, patternId);
675
+ }
676
+ }
677
+ }
678
+
679
+ for (const [componentId, meta] of Object.entries(components)) {
680
+ const deps = Array.isArray(meta?.deps) ? meta.deps : [];
681
+ for (const dep of deps) {
682
+ const depId = String(dep ?? '').trim();
683
+ addRelation(componentId, depId, 'dependency');
684
+ addRelation(depId, componentId, 'dependencyOf');
685
+ }
686
+ }
687
+
688
+ return related;
689
+ }
690
+
691
+ export function getRelatedComponentsForTag({ canonicalTagName, installRegistry, relatedMap, prefix, maxResults = 12 }) {
692
+ const tags = installRegistry?.tags && typeof installRegistry.tags === 'object' ? installRegistry.tags : {};
693
+ const components = installRegistry?.components && typeof installRegistry.components === 'object' ? installRegistry.components : {};
694
+ const componentId = typeof canonicalTagName === 'string' ? tags[canonicalTagName] : undefined;
695
+ if (typeof componentId !== 'string' || componentId === '') return [];
696
+
697
+ const relMap = relatedMap?.get(componentId);
698
+ if (!relMap) return [];
699
+
700
+ const out = [];
701
+ for (const [relatedId, via] of relMap.entries()) {
702
+ const relatedMeta = components[relatedId];
703
+ if (!relatedMeta || typeof relatedMeta !== 'object') continue;
704
+
705
+ const canonicalTags = Array.isArray(relatedMeta.tags)
706
+ ? relatedMeta.tags.map((tag) => String(tag ?? '').toLowerCase()).filter(Boolean)
707
+ : [];
708
+
709
+ out.push({
710
+ componentId: relatedId,
711
+ tagNames: canonicalTags.map((tag) => withPrefix(tag, prefix)),
712
+ via: [...via],
713
+ });
714
+ }
715
+
716
+ out.sort((left, right) => String(left.componentId).localeCompare(String(right.componentId)));
717
+ return out.slice(0, Math.max(1, maxResults));
718
+ }
719
+
720
+ export function normalizeWcagLevel(level) {
721
+ const raw = typeof level === 'string' ? level.trim().toUpperCase() : '';
722
+ if (!raw || raw === 'ALL') return 'all';
723
+ return WCAG_LEVELS.has(raw) ? raw : 'all';
724
+ }
725
+
726
+ function getWcagLevelForA11yTopic(topic) {
727
+ const key = String(topic ?? '').trim().toLowerCase();
728
+ return A11Y_CATEGORY_LEVEL_MAP[key] ?? 'A';
729
+ }
730
+
731
+ function toChecklistItemsFromCategories(categories) {
732
+ if (!categories || typeof categories !== 'object') return [];
733
+
734
+ const out = [];
735
+ for (const [topic, checks] of Object.entries(categories)) {
736
+ if (!Array.isArray(checks)) continue;
737
+ const wcagLevel = getWcagLevelForA11yTopic(topic);
738
+ for (const check of checks) {
739
+ const text = String(check ?? '').trim();
740
+ if (!text) continue;
741
+ out.push({
742
+ topic: String(topic),
743
+ wcagLevel,
744
+ check: text,
745
+ });
746
+ }
747
+ }
748
+ return out;
749
+ }
750
+
751
+ function toChecklistItemsFromCallouts(callouts) {
752
+ if (!Array.isArray(callouts)) return [];
753
+
754
+ const out = [];
755
+ for (const callout of callouts) {
756
+ const parts = [
757
+ callout?.title,
758
+ callout?.label,
759
+ callout?.description,
760
+ ...(Array.isArray(callout?.highlights) ? callout.highlights : []),
761
+ ]
762
+ .map((value) => String(value ?? '').trim())
763
+ .filter(Boolean);
764
+
765
+ if (parts.length === 0) continue;
766
+ out.push({
767
+ topic: 'callouts',
768
+ wcagLevel: getWcagLevelForA11yTopic('callouts'),
769
+ check: parts.join(' — '),
770
+ });
771
+ }
772
+ return out;
773
+ }
774
+
775
+ export function extractAccessibilityChecklist(decl, { prefix } = {}) {
776
+ const annotations = decl?.custom?.a11yAnnotations;
777
+ if (!annotations || typeof annotations !== 'object') return undefined;
778
+
779
+ const items = [
780
+ ...toChecklistItemsFromCategories(annotations.categories),
781
+ ...toChecklistItemsFromCallouts(annotations.callouts),
782
+ ];
783
+ if (items.length === 0) return undefined;
784
+
785
+ const unique = new Map();
786
+ for (const item of items) {
787
+ const key = `${item.topic}|${item.wcagLevel}|${item.check}`;
788
+ if (!unique.has(key)) unique.set(key, item);
789
+ }
790
+
791
+ return {
792
+ summary: String(annotations.summary ?? '').trim() || 'Component accessibility checklist',
793
+ version: Number.isInteger(annotations.version) ? annotations.version : 1,
794
+ totalChecks: unique.size,
795
+ items: [...unique.values()],
796
+ componentTagName:
797
+ typeof decl?.tagName === 'string' ? withPrefix(decl.tagName.toLowerCase(), prefix) : undefined,
798
+ };
799
+ }
800
+
801
+ export function buildAccessibilityIndex(indexes, guidelinesIndexData, { prefix } = {}) {
802
+ const out = [];
803
+
804
+ for (const { decl, tagName } of indexes.decls) {
805
+ const checklist = extractAccessibilityChecklist(decl, { prefix });
806
+ if (!checklist) continue;
807
+ const className = typeof decl?.name === 'string' ? decl.name : undefined;
808
+
809
+ for (const item of checklist.items) {
810
+ out.push({
811
+ source: 'component',
812
+ componentTagName: withPrefix(tagName, prefix),
813
+ componentClassName: className,
814
+ topic: item.topic,
815
+ wcagLevel: item.wcagLevel,
816
+ check: item.check,
817
+ });
818
+ }
819
+ }
820
+
821
+ const docs = Array.isArray(guidelinesIndexData?.documents)
822
+ ? guidelinesIndexData.documents.filter((doc) => doc?.topic === 'accessibility')
823
+ : [];
824
+
825
+ for (const doc of docs) {
826
+ const sections = Array.isArray(doc?.sections) ? doc.sections : [];
827
+ for (const section of sections) {
828
+ const heading = String(section?.heading ?? '').trim();
829
+ const snippet = String(section?.snippet ?? '').trim();
830
+ if (!heading && !snippet) continue;
831
+
832
+ out.push({
833
+ source: 'guideline',
834
+ documentId: String(doc?.id ?? ''),
835
+ title: String(doc?.title ?? ''),
836
+ heading,
837
+ topic: 'guideline',
838
+ wcagLevel: getWcagLevelForA11yTopic('guideline'),
839
+ check: snippet || heading,
840
+ });
841
+ }
842
+ }
843
+
844
+ return out;
845
+ }
846
+
847
+ export function queryAccessibilityIndex(
848
+ entries,
849
+ { componentTagName, topic, wcagLevel, maxResults = 20 } = {},
850
+ ) {
851
+ const normalizedTopic = String(topic ?? '').trim().toLowerCase() || 'all';
852
+ const normalizedWcagLevel = normalizeWcagLevel(wcagLevel);
853
+ const pageSize = Number.isInteger(maxResults) ? Math.max(1, Math.min(maxResults, 100)) : 20;
854
+ const source = Array.isArray(entries) ? entries : [];
855
+ const results = [];
856
+ const shouldBalanceSources = !componentTagName && normalizedTopic === 'all';
857
+ const guidelineCandidates = [];
858
+ const componentCandidates = [];
859
+ const otherCandidates = [];
860
+ let totalHits = 0;
861
+
862
+ for (const entry of source) {
863
+ if (componentTagName && entry.componentTagName !== componentTagName) continue;
864
+ if (normalizedTopic !== 'all' && String(entry.topic ?? '').toLowerCase() !== normalizedTopic) continue;
865
+ if (normalizedWcagLevel !== 'all' && String(entry.wcagLevel ?? '').toUpperCase() !== normalizedWcagLevel) continue;
866
+
867
+ totalHits += 1;
868
+ if (!shouldBalanceSources) {
869
+ if (results.length < pageSize) results.push(entry);
870
+ continue;
871
+ }
872
+
873
+ if (String(entry.source ?? '') === 'guideline') {
874
+ if (guidelineCandidates.length < pageSize) guidelineCandidates.push(entry);
875
+ } else if (String(entry.source ?? '') === 'component') {
876
+ if (componentCandidates.length < pageSize) componentCandidates.push(entry);
877
+ } else if (otherCandidates.length < pageSize) {
878
+ otherCandidates.push(entry);
879
+ }
880
+ }
881
+
882
+ if (shouldBalanceSources) {
883
+ while (results.length < pageSize) {
884
+ const beforeLength = results.length;
885
+ if (guidelineCandidates.length > 0) results.push(guidelineCandidates.shift());
886
+ if (results.length < pageSize && componentCandidates.length > 0) results.push(componentCandidates.shift());
887
+ if (results.length < pageSize && otherCandidates.length > 0) results.push(otherCandidates.shift());
888
+ if (results.length === beforeLength) break;
889
+ }
890
+ }
891
+
892
+ return {
893
+ topic: normalizedTopic,
894
+ wcagLevel: normalizedWcagLevel,
895
+ totalHits,
896
+ results,
897
+ };
898
+ }
899
+
900
+ function resolveDeclByComponent(indexes, component, prefix) {
901
+ const byTagOrClass =
902
+ pickDecl(indexes, { tagName: component, prefix }) ??
903
+ pickDecl(indexes, { className: component, prefix });
904
+ if (byTagOrClass) {
905
+ const canonicalTag = typeof byTagOrClass.tagName === 'string' ? byTagOrClass.tagName.toLowerCase() : undefined;
906
+ return {
907
+ decl: byTagOrClass,
908
+ modulePath: canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined,
909
+ };
910
+ }
911
+
912
+ return findDeclByComponentId(indexes, component);
913
+ }
914
+
330
915
  // ---------------------------------------------------------------------------
331
916
  // createMcpServer — builds the McpServer with all tools registered, but does
332
917
  // NOT connect a transport. Callers choose their own transport.
@@ -338,11 +923,17 @@ export function resolveComponentClosure({ installRegistry }, componentIds) {
338
923
  export async function createMcpServer(loadJsonData, loadValidator) {
339
924
  const manifest = await loadJsonData('custom-elements.json');
340
925
  const indexes = buildIndexes(manifest);
341
- const { collectCemCustomElements, validateTextAgainstCem } = await loadValidator();
926
+ const {
927
+ collectCemCustomElements,
928
+ validateTextAgainstCem,
929
+ detectTokenMisuseInInlineStyles = () => [],
930
+ detectAccessibilityMisuseInMarkup = () => [],
931
+ } = await loadValidator();
342
932
  const canonicalCemIndex = collectCemCustomElements(manifest);
343
933
  const installRegistry = await loadJsonData('install-registry.json');
344
934
  const patternRegistry = await loadJsonData('pattern-registry.json');
345
935
  const { patterns } = loadPatternRegistryShape(patternRegistry);
936
+ const relatedComponentMap = buildRelatedComponentMap(installRegistry, patterns);
346
937
 
347
938
  // Load optional data files (design tokens, guidelines index)
348
939
  let designTokensData = null;
@@ -359,6 +950,8 @@ export async function createMcpServer(loadJsonData, loadValidator) {
359
950
  // guidelines-index.json may not exist yet
360
951
  }
361
952
 
953
+ const tokenSuggestionMap = buildTokenSuggestionMap(designTokensData);
954
+
362
955
  const server = new McpServer({
363
956
  name: 'web-components-factory-design-system',
364
957
  version: '0.1.1',
@@ -394,9 +987,11 @@ export async function createMcpServer(loadJsonData, loadValidator) {
394
987
  componentsByCategory: categoryCount,
395
988
  totalPatterns: patternList.length,
396
989
  patterns: patternList,
990
+ ideSetupTemplates: IDE_SETUP_TEMPLATES,
397
991
  availableTools: [
398
992
  { name: 'get_design_system_overview', purpose: 'This overview (start here)' },
399
- { name: 'list_components', purpose: 'Browse components, optionally filter by category' },
993
+ { name: 'list_components', purpose: 'Browse components with progressive disclosure and filters' },
994
+ { name: 'search_icons', purpose: 'Search icon names and usage examples' },
400
995
  { name: 'get_component_api', purpose: 'Full API surface for a single component' },
401
996
  { name: 'generate_usage_snippet', purpose: 'Minimal HTML usage example' },
402
997
  { name: 'get_install_recipe', purpose: 'Installation instructions and dependency tree' },
@@ -405,17 +1000,20 @@ export async function createMcpServer(loadJsonData, loadValidator) {
405
1000
  { name: 'get_pattern_recipe', purpose: 'Full pattern recipe with dependencies and HTML' },
406
1001
  { name: 'generate_pattern_snippet', purpose: 'Pattern HTML snippet only' },
407
1002
  { name: 'get_design_tokens', purpose: 'Query design tokens (colors, spacing, typography, radius, shadows)' },
1003
+ { name: 'get_accessibility_docs', purpose: 'Search component-level accessibility checklist and WCAG-filtered guidance' },
408
1004
  { name: 'search_guidelines', purpose: 'Search design system guidelines and best practices' },
409
1005
  ],
410
1006
  recommendedWorkflow: [
411
- '1. get_design_system_overview → understand components, patterns & tokens',
1007
+ '1. get_design_system_overview → understand components, patterns, tokens, and IDE setup templates',
412
1008
  '2. search_guidelines → find relevant guidelines',
413
1009
  '3. get_design_tokens → get correct token values',
414
- '4. list_components (category filter) find the right components',
415
- '5. get_component_api check attributes, slots, events, CSS parts',
416
- '6. generate_usage_snippet or get_pattern_recipe get code',
417
- '7. validate_markupverify your HTML is correct',
418
- '8. get_install_recipe → get import/install instructions',
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_recipe → get code',
1015
+ '9. validate_markup → verify your HTML and use suggestions to self-correct',
1016
+ '10. get_install_recipe → get import/install instructions',
419
1017
  ],
420
1018
  };
421
1019
 
@@ -432,31 +1030,45 @@ export async function createMcpServer(loadJsonData, loadValidator) {
432
1030
  'list_components',
433
1031
  {
434
1032
  description:
435
- 'List custom elements in the design system. When: exploring available components or filtering by category. Returns: array of {tagName, className, description, category}. After: use get_component_api for details on a specific component.',
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.',
436
1034
  inputSchema: {
437
1035
  category: z
438
1036
  .enum(['Form', 'Actions', 'Navigation', 'Content', 'Display', 'Layout', 'Other'])
439
1037
  .optional()
440
1038
  .describe('Filter by component category'),
1039
+ 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)'),
1041
+ offset: z.number().int().min(0).optional().describe('Pagination offset (default: 0)'),
441
1042
  prefix: z.string().optional(),
442
1043
  },
443
1044
  },
444
- async ({ category, prefix }) => {
445
- const p = normalizePrefix(prefix);
446
- let list = indexes.decls.map(({ decl, tagName, modulePath }) => ({
447
- tagName: withPrefix(tagName, p),
448
- className: typeof decl?.name === 'string' ? decl.name : undefined,
449
- description: typeof decl?.description === 'string' ? decl.description : undefined,
450
- category: getCategory(tagName),
451
- modulePath,
452
- }));
453
-
454
- if (category) {
455
- list = list.filter((item) => item.category === category);
456
- }
1045
+ async ({ category, query, limit, offset, prefix }) => {
1046
+ const { items } = buildComponentSummaries(indexes, { category, query, limit, offset, prefix });
1047
+ return {
1048
+ content: [{ type: 'text', text: JSON.stringify(items, null, 2) }],
1049
+ };
1050
+ },
1051
+ );
457
1052
 
1053
+ // -----------------------------------------------------------------------
1054
+ // Tool: search_icons
1055
+ // -----------------------------------------------------------------------
1056
+ server.registerTool(
1057
+ 'search_icons',
1058
+ {
1059
+ description:
1060
+ 'Search icon catalog by keyword. When: you need a valid icon name for dads-icon or icon-capable components. Returns: { total, limit, offset, hasMore, icons[] } with name, variants, and usageExample. After: use the icon name in generate_usage_snippet or your markup.',
1061
+ inputSchema: {
1062
+ query: z.string().optional().describe('Search icon names (partial match)'),
1063
+ limit: z.number().int().min(1).max(100).optional().describe('Maximum items to return (default: 20)'),
1064
+ offset: z.number().int().min(0).optional().describe('Pagination offset (default: 0)'),
1065
+ prefix: z.string().optional(),
1066
+ },
1067
+ },
1068
+ async ({ query, limit, offset, prefix }) => {
1069
+ const payload = searchIconCatalog(indexes, { query, limit, offset, prefix });
458
1070
  return {
459
- content: [{ type: 'text', text: JSON.stringify(list, null, 2) }],
1071
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
460
1072
  };
461
1073
  },
462
1074
  );
@@ -492,10 +1104,21 @@ export async function createMcpServer(loadJsonData, loadValidator) {
492
1104
  const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
493
1105
  const modulePath = canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : undefined;
494
1106
  const api = serializeApi(decl, modulePath, prefix);
1107
+ const relatedComponents = getRelatedComponentsForTag({
1108
+ canonicalTagName: canonicalTag,
1109
+ installRegistry,
1110
+ relatedMap: relatedComponentMap,
1111
+ prefix,
1112
+ });
1113
+ if (relatedComponents.length > 0) {
1114
+ api.relatedComponents = relatedComponents;
1115
+ }
1116
+ const accessibilityChecklist = extractAccessibilityChecklist(decl, { prefix });
1117
+ if (accessibilityChecklist) {
1118
+ api.accessibilityChecklist = accessibilityChecklist;
1119
+ }
495
1120
 
496
- return {
497
- content: [{ type: 'text', text: JSON.stringify(api, null, 2) }],
498
- };
1121
+ return buildJsonToolResponse(api);
499
1122
  },
500
1123
  );
501
1124
 
@@ -550,13 +1173,8 @@ export async function createMcpServer(loadJsonData, loadValidator) {
550
1173
  },
551
1174
  async ({ component, prefix }) => {
552
1175
  const p = normalizePrefix(prefix);
553
-
554
- const byTagOrClass =
555
- pickDecl(indexes, { tagName: component, prefix: p }) ??
556
- pickDecl(indexes, { className: component, prefix: p });
557
-
558
- const byComponentId = byTagOrClass ? undefined : findDeclByComponentId(indexes, component);
559
- const decl = byTagOrClass ?? byComponentId?.decl;
1176
+ const resolved = resolveDeclByComponent(indexes, component, p);
1177
+ const decl = resolved?.decl;
560
1178
 
561
1179
  if (!decl) {
562
1180
  return {
@@ -566,8 +1184,7 @@ export async function createMcpServer(loadJsonData, loadValidator) {
566
1184
  }
567
1185
 
568
1186
  const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
569
- const modulePath =
570
- canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : byComponentId?.modulePath;
1187
+ const modulePath = canonicalTag ? indexes.modulePathByTag.get(canonicalTag) : resolved?.modulePath;
571
1188
  const api = serializeApi(decl, modulePath, p);
572
1189
  const usageSnippet = generateSnippet(api, p);
573
1190
 
@@ -633,7 +1250,7 @@ export async function createMcpServer(loadJsonData, loadValidator) {
633
1250
  'validate_markup',
634
1251
  {
635
1252
  description:
636
- 'Validate HTML against the design system Custom Elements Manifest. When: checking generated or written HTML for correctness. Returns: diagnostics array with errors (unknown elements) and warnings (unknown attributes). Use after generating HTML to catch mistakes.',
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.',
637
1254
  inputSchema: {
638
1255
  html: z.string(),
639
1256
  prefix: z.string().optional(),
@@ -649,7 +1266,7 @@ export async function createMcpServer(loadJsonData, loadValidator) {
649
1266
  cemIndex = combined;
650
1267
  }
651
1268
 
652
- const diagnostics = validateTextAgainstCem({
1269
+ const cemDiagnostics = validateTextAgainstCem({
653
1270
  filePath: '<markup>',
654
1271
  text: html,
655
1272
  cem: cemIndex,
@@ -657,16 +1274,35 @@ export async function createMcpServer(loadJsonData, loadValidator) {
657
1274
  unknownElement: 'error',
658
1275
  unknownAttribute: 'warning',
659
1276
  },
660
- }).map((d) => ({
661
- file: d.file,
662
- range: d.range,
663
- severity: d.severity,
664
- code: d.code,
665
- message: d.message,
666
- tagName: d.tagName,
667
- attrName: d.attrName,
668
- hint: d.hint,
669
- }));
1277
+ });
1278
+
1279
+ const tokenMisuseDiagnostics = detectTokenMisuseInInlineStyles({
1280
+ filePath: '<markup>',
1281
+ text: html,
1282
+ valueToToken: tokenSuggestionMap,
1283
+ severity: 'warning',
1284
+ });
1285
+
1286
+ const accessibilityDiagnostics = detectAccessibilityMisuseInMarkup({
1287
+ filePath: '<markup>',
1288
+ text: html,
1289
+ severity: 'warning',
1290
+ });
1291
+
1292
+ const diagnostics = [...cemDiagnostics, ...tokenMisuseDiagnostics, ...accessibilityDiagnostics].map((d) => {
1293
+ const suggestion = buildDiagnosticSuggestion({ diagnostic: d, cemIndex });
1294
+ return {
1295
+ file: d.file,
1296
+ range: d.range,
1297
+ severity: d.severity,
1298
+ code: d.code,
1299
+ message: d.message,
1300
+ tagName: d.tagName,
1301
+ attrName: d.attrName,
1302
+ hint: d.hint,
1303
+ suggestion,
1304
+ };
1305
+ });
670
1306
 
671
1307
  return {
672
1308
  content: [{ type: 'text', text: JSON.stringify({ diagnostics }, null, 2) }],
@@ -841,16 +1477,78 @@ export async function createMcpServer(loadJsonData, loadValidator) {
841
1477
  tokens = tokens.filter((t) => t.name.toLowerCase().includes(q));
842
1478
  }
843
1479
 
844
- return {
845
- content: [{
846
- type: 'text',
847
- text: JSON.stringify({
848
- total: tokens.length,
849
- tokens,
850
- summary: designTokensData.summary,
851
- }, null, 2),
852
- }],
1480
+ const payload = {
1481
+ total: tokens.length,
1482
+ tokens,
1483
+ summary: designTokensData.summary,
853
1484
  };
1485
+
1486
+ return buildJsonToolResponse(payload);
1487
+ },
1488
+ );
1489
+
1490
+ // -----------------------------------------------------------------------
1491
+ // Tool: get_accessibility_docs
1492
+ // -----------------------------------------------------------------------
1493
+ server.registerTool(
1494
+ 'get_accessibility_docs',
1495
+ {
1496
+ description:
1497
+ 'Get accessibility guidance and component checklist entries. ' +
1498
+ 'When: validating accessibility decisions, reviewing ARIA usage, or checking WCAG-focused implementation notes. ' +
1499
+ 'Returns: filtered checklist entries from component a11y annotations and accessibility guidelines. ' +
1500
+ 'After: apply the checks in your markup and run validate_markup.',
1501
+ inputSchema: {
1502
+ component: z.string().optional()
1503
+ .describe('Filter by component tagName/className/componentId'),
1504
+ topic: z.string().optional()
1505
+ .describe('Filter by topic (e.g. semantics, keyboard, labels, states, zoom, motion, callouts, guideline)'),
1506
+ wcagLevel: z.enum(['A', 'AA', 'AAA', 'all']).optional()
1507
+ .describe('Filter by WCAG level (default: all)'),
1508
+ maxResults: z.number().int().min(1).max(100).optional()
1509
+ .describe('Maximum results to return (default: 20)'),
1510
+ prefix: z.string().optional(),
1511
+ },
1512
+ },
1513
+ async ({ component, topic, wcagLevel, maxResults, prefix }) => {
1514
+ const p = normalizePrefix(prefix);
1515
+ let componentTagName;
1516
+
1517
+ if (typeof component === 'string' && component.trim() !== '') {
1518
+ const decl = resolveDeclByComponent(indexes, component, p)?.decl;
1519
+
1520
+ if (!decl || typeof decl?.tagName !== 'string') {
1521
+ return {
1522
+ content: [{
1523
+ type: 'text',
1524
+ text: `Component not found (component=${component})`,
1525
+ }],
1526
+ isError: true,
1527
+ };
1528
+ }
1529
+
1530
+ componentTagName = withPrefix(decl.tagName.toLowerCase(), p);
1531
+ }
1532
+
1533
+ const entries = buildAccessibilityIndex(indexes, guidelinesIndexData, { prefix: p });
1534
+ const result = queryAccessibilityIndex(entries, {
1535
+ componentTagName,
1536
+ topic,
1537
+ wcagLevel,
1538
+ maxResults,
1539
+ });
1540
+
1541
+ const payload = {
1542
+ query: {
1543
+ component: componentTagName ?? null,
1544
+ topic: result.topic,
1545
+ wcagLevel: result.wcagLevel,
1546
+ },
1547
+ totalHits: result.totalHits,
1548
+ results: result.results,
1549
+ };
1550
+
1551
+ return buildJsonToolResponse(payload);
854
1552
  },
855
1553
  );
856
1554
 
@@ -930,17 +1628,14 @@ export async function createMcpServer(loadJsonData, loadValidator) {
930
1628
  results.sort((a, b) => b.score - a.score);
931
1629
  const topResults = results.slice(0, max);
932
1630
 
933
- return {
934
- content: [{
935
- type: 'text',
936
- text: JSON.stringify({
937
- query,
938
- topic: topic ?? 'all',
939
- totalHits: results.length,
940
- results: topResults,
941
- }, null, 2),
942
- }],
1631
+ const payload = {
1632
+ query,
1633
+ topic: topic ?? 'all',
1634
+ totalHits: results.length,
1635
+ results: topResults,
943
1636
  };
1637
+
1638
+ return buildJsonToolResponse(payload);
944
1639
  },
945
1640
  );
946
1641