@monoharada/wcf-mcp 0.9.1 → 0.11.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/register.mjs CHANGED
@@ -2,9 +2,11 @@
2
2
  * core/register.mjs — Tool / Resource / Prompt registration logic for the MCP server.
3
3
  */
4
4
 
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
5
7
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
6
8
  import { z } from 'zod';
7
- import { CANONICAL_PREFIX, PACKAGE_VERSION, PLUGIN_TOOL_NOTICE, FIGMA_TO_WCF_PROMPT, WCF_RESOURCE_URIS, IDE_SETUP_TEMPLATES } from './constants.mjs';
9
+ import { CANONICAL_PREFIX, PACKAGE_VERSION, PLUGIN_TOOL_NOTICE, FIGMA_TO_WCF_PROMPT, BUILD_PAGE_PROMPT, WCF_RESOURCE_URIS, IDE_SETUP_TEMPLATES } from './constants.mjs';
8
10
  import { normalizePrefix, withPrefix, toCanonicalTagName, getCategory, buildDiagnosticSuggestion, applyPrefixToHtml, applyPrefixToTagMap, mergeWithPrefixed } from './prefix.mjs';
9
11
  import { buildJsonToolResponse, buildJsonToolErrorResponse, expandQueryWithSynonyms, finalizeToolResult } from './response.mjs';
10
12
  import { normalizePlugins, buildPluginDataSourceMap, toPassthroughSchema } from './plugins.mjs';
@@ -128,6 +130,77 @@ function buildFigmaToWcfPromptText({ figmaUrl, userIntent }) {
128
130
  ].join('\n');
129
131
  }
130
132
 
133
+ function buildBuildPagePromptText({ patternId, components: componentsCsv, userIntent, detectedPrefix, installRegistry, patterns }) {
134
+ const p = normalizePrefix(detectedPrefix);
135
+ const intent = String(userIntent ?? '').trim();
136
+ const registryComponents = installRegistry?.components && typeof installRegistry.components === 'object'
137
+ ? installRegistry.components : {};
138
+ const componentIds = Object.keys(registryComponents).sort();
139
+ const patternIds = patterns && typeof patterns === 'object'
140
+ ? Object.keys(patterns).sort() : [];
141
+
142
+ const lines = [
143
+ intent ? `Page goal: ${intent}` : 'Page goal: (not specified)',
144
+ '',
145
+ ];
146
+
147
+ // patternId takes priority over components when both are specified
148
+ if (patternId) {
149
+ lines.push(
150
+ '## Using a Pattern',
151
+ `1. get_pattern_recipe({ patternId: "${patternId}", include: ["fullPage"] })`,
152
+ '2. validate_markup({ html: "<the full page HTML>" }) — pass the entire page to catch missing importmap / boot script',
153
+ '3. Save the fullPageHtml to a .html file',
154
+ '',
155
+ );
156
+ if (componentsCsv) {
157
+ lines.push(
158
+ '> Note: patternId was specified, so the components argument is ignored. Remove patternId to use individual components instead.',
159
+ '',
160
+ );
161
+ }
162
+ } else if (componentsCsv) {
163
+ const ids = componentsCsv.split(',').map((s) => s.trim()).filter(Boolean);
164
+ lines.push(
165
+ '## Using Specific Components',
166
+ ...ids.map((id) => `- generate_usage_snippet({ component: "${id}" })`),
167
+ '- Combine the HTML fragments',
168
+ '- generate_full_page_html({ html: "<combined fragments>" }) → returns fullHtml',
169
+ '- validate_markup({ html: "<the full page HTML>" }) — pass the entire page to catch missing importmap / boot script',
170
+ '',
171
+ );
172
+ } else {
173
+ lines.push(
174
+ '## Workflow Options',
175
+ '',
176
+ '### Option A: Use a pattern (recommended)',
177
+ '1. get_pattern_recipe({ patternId: "<id>", include: ["fullPage"] })',
178
+ '2. validate_markup({ html: "<the full page HTML>" }) — pass the entire page to catch missing importmap / boot script',
179
+ '3. Save the fullPageHtml to a .html file',
180
+ '',
181
+ '### Option B: Build from individual components',
182
+ '1. generate_usage_snippet({ component: "<componentId>" }) for each component',
183
+ '2. Combine the HTML fragments',
184
+ '3. generate_full_page_html({ html: "<combined fragments>" }) → returns fullHtml',
185
+ '4. validate_markup({ html: "<the full page HTML>" }) — pass the entire page to catch missing importmap / boot script',
186
+ '',
187
+ );
188
+ }
189
+
190
+ lines.push(
191
+ `## Available Pattern IDs (${patternIds.length})`,
192
+ patternIds.length > 0 ? patternIds.join(', ') : '(none)',
193
+ '',
194
+ `## Available Component IDs (${componentIds.length})`,
195
+ componentIds.length > 0 ? componentIds.join(', ') : '(none)',
196
+ '',
197
+ '## CLI Vendor Setup (alternative)',
198
+ `npx web-components-factory init --prefix ${p} --dir .` + (patternId ? ` --pattern ${patternId}` : ''),
199
+ );
200
+
201
+ return lines.join('\n');
202
+ }
203
+
131
204
  function escapeHtmlTitle(s) {
132
205
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
133
206
  }
@@ -146,6 +219,362 @@ function buildImportMapEntries(closure, components, prefix, dir, prefixStripRe)
146
219
  );
147
220
  }
148
221
 
222
+ function scoreSearchFields(query, terms, fields) {
223
+ let score = 0;
224
+ const matchedTerms = new Set();
225
+ for (const { text, weight } of fields) {
226
+ const normalized = String(text ?? '').toLowerCase();
227
+ if (!normalized) continue;
228
+ if (query && normalized === query) {
229
+ score += weight * 6;
230
+ matchedTerms.add(query);
231
+ continue;
232
+ }
233
+ if (query && normalized.startsWith(query)) {
234
+ score += weight * 3;
235
+ matchedTerms.add(query);
236
+ } else if (query && normalized.includes(query)) {
237
+ score += weight * 2;
238
+ matchedTerms.add(query);
239
+ }
240
+ for (const term of terms) {
241
+ if (!term) continue;
242
+ if (normalized.includes(term)) {
243
+ score += weight;
244
+ matchedTerms.add(term);
245
+ }
246
+ }
247
+ }
248
+ score += matchedTerms.size * 2;
249
+ return score;
250
+ }
251
+
252
+ function detectKnowledgeIntentSources(query, terms) {
253
+ const raw = `${query} ${terms.join(' ')}`.toLowerCase();
254
+ const intents = new Set();
255
+
256
+ if (
257
+ raw.includes('guideline') ||
258
+ raw.includes('rule') ||
259
+ raw.includes('a11y') ||
260
+ raw.includes('accessibility') ||
261
+ raw.includes('wcag') ||
262
+ raw.includes('aria') ||
263
+ raw.includes('keyboard') ||
264
+ raw.includes('focus') ||
265
+ raw.includes('contrast') ||
266
+ raw.includes('::part')
267
+ ) intents.add('guidelines');
268
+
269
+ if (
270
+ raw.includes('token') ||
271
+ raw.includes('css variable') ||
272
+ raw.includes('spacing') ||
273
+ raw.includes('color') ||
274
+ raw.includes('typography') ||
275
+ raw.includes('radius') ||
276
+ raw.includes('shadow') ||
277
+ query.startsWith('--') ||
278
+ query.includes('var(')
279
+ ) intents.add('tokens');
280
+
281
+ if (
282
+ raw.includes('pattern') ||
283
+ raw.includes('layout') ||
284
+ raw.includes('page') ||
285
+ raw.includes('screen') ||
286
+ raw.includes('shell') ||
287
+ raw.includes('dashboard') ||
288
+ raw.includes('template')
289
+ ) intents.add('patterns');
290
+
291
+ if (
292
+ raw.includes('skill') ||
293
+ raw.includes('workflow') ||
294
+ raw.includes('codex') ||
295
+ raw.includes('claude') ||
296
+ raw.includes('cursor') ||
297
+ raw.includes('prompt')
298
+ ) intents.add('skills');
299
+
300
+ if (
301
+ query.startsWith('dads-') ||
302
+ /^[a-z0-9-]+$/.test(query) ||
303
+ /^[A-Z][A-Za-z0-9]+$/.test(query)
304
+ ) intents.add('components');
305
+
306
+ return intents;
307
+ }
308
+
309
+ function getKnowledgeSourceBoost(source, query, terms) {
310
+ const intents = detectKnowledgeIntentSources(query, terms);
311
+ return intents.has(source) ? 6 : 0;
312
+ }
313
+
314
+ function selectKnowledgeResults(results, limit, requestedSources) {
315
+ if (requestedSources.size <= 1) {
316
+ return results.slice(0, limit);
317
+ }
318
+
319
+ const selected = [];
320
+ const deferred = [];
321
+ const sourceCounts = new Map();
322
+ const softCap = Math.max(1, Math.ceil(limit / requestedSources.size));
323
+
324
+ for (const result of results) {
325
+ const sourceCount = sourceCounts.get(result.source) ?? 0;
326
+ if (sourceCount < softCap) {
327
+ selected.push(result);
328
+ sourceCounts.set(result.source, sourceCount + 1);
329
+ if (selected.length >= limit) return selected;
330
+ continue;
331
+ }
332
+ deferred.push(result);
333
+ }
334
+
335
+ for (const result of deferred) {
336
+ selected.push(result);
337
+ if (selected.length >= limit) break;
338
+ }
339
+
340
+ return selected;
341
+ }
342
+
343
+ function buildKnowledgeFollowUp(result) {
344
+ switch (result.source) {
345
+ case 'components':
346
+ return {
347
+ tool: 'get_component_api',
348
+ arguments: { component: result.id },
349
+ };
350
+ case 'patterns':
351
+ return {
352
+ tool: 'get_pattern_recipe',
353
+ arguments: { patternId: result.id },
354
+ };
355
+ case 'guidelines':
356
+ return {
357
+ tool: 'search_guidelines',
358
+ arguments: {
359
+ query: result.title,
360
+ topic: result.metadata?.topic ?? 'all',
361
+ },
362
+ };
363
+ case 'tokens':
364
+ return {
365
+ tool: 'get_design_token_detail',
366
+ arguments: { name: result.id },
367
+ };
368
+ case 'skills':
369
+ return {
370
+ resource: 'wcf://skills',
371
+ hint: `Filter skill "${result.id}" from the skills catalog, or use get_skill_manifest when the design-system-skills plugin is enabled.`,
372
+ };
373
+ default:
374
+ return undefined;
375
+ }
376
+ }
377
+
378
+ function escapeRegex(value) {
379
+ return String(value).replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
380
+ }
381
+
382
+ function isGlobLike(pattern) {
383
+ return /[*?[\]{}()]/.test(pattern);
384
+ }
385
+
386
+ function matchesGlobPattern(filePath, pattern) {
387
+ const file = filePath.split(path.sep).join('/');
388
+ const pat = pattern.split(path.sep).join('/');
389
+ if (!isGlobLike(pat)) return file === pat;
390
+ if (pat.endsWith('/**')) {
391
+ const prefix = pat.slice(0, -3);
392
+ return file === prefix || file.startsWith(`${prefix}/`);
393
+ }
394
+
395
+ const normalizedPattern = pat
396
+ .replaceAll('**/', '§§DOUBLE_STAR_DIR§§')
397
+ .replaceAll('/**', '§§DIR_DOUBLE_STAR§§')
398
+ .replaceAll('**', '§§DOUBLE_STAR§§');
399
+
400
+ const reSrc =
401
+ '^' +
402
+ escapeRegex(normalizedPattern)
403
+ .replaceAll('\\*', '[^/]*')
404
+ .replaceAll('\\?', '.')
405
+ .replaceAll('§§DOUBLE_STAR_DIR§§', '(?:.*/)?')
406
+ .replaceAll('§§DIR_DOUBLE_STAR§§', '(?:/.*)?')
407
+ .replaceAll('§§DOUBLE_STAR§§', '.*') +
408
+ '$';
409
+ return new RegExp(reSrc).test(file);
410
+ }
411
+
412
+ async function walkProjectFiles(rootDir) {
413
+ const out = [];
414
+ const queue = [rootDir];
415
+
416
+ while (queue.length > 0) {
417
+ const current = queue.shift();
418
+ const entries = await fs.readdir(current, { withFileTypes: true });
419
+ for (const entry of entries) {
420
+ const absolutePath = path.join(current, entry.name);
421
+ if (entry.isDirectory()) {
422
+ queue.push(absolutePath);
423
+ continue;
424
+ }
425
+ if (entry.isFile()) out.push(absolutePath);
426
+ }
427
+ }
428
+
429
+ return out;
430
+ }
431
+
432
+ function summarizeDiagnostics(diagnostics) {
433
+ const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length;
434
+ const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === 'warning').length;
435
+ return {
436
+ total: diagnostics.length,
437
+ errorCount,
438
+ warningCount,
439
+ };
440
+ }
441
+
442
+ function normalizePluginValidatorDiagnostics(result, { pluginName, validatorName, filePath }) {
443
+ const diagnostics = Array.isArray(result)
444
+ ? result
445
+ : Array.isArray(result?.diagnostics)
446
+ ? result.diagnostics
447
+ : [];
448
+
449
+ return diagnostics
450
+ .filter((diagnostic) => diagnostic && typeof diagnostic === 'object')
451
+ .map((diagnostic) => ({
452
+ file: diagnostic.file ?? filePath,
453
+ range: diagnostic.range,
454
+ severity: diagnostic.severity ?? 'warning',
455
+ code: String(diagnostic.code ?? 'pluginValidationIssue'),
456
+ message: String(diagnostic.message ?? `Plugin validator reported an issue (${pluginName}/${validatorName}).`),
457
+ tagName: diagnostic.tagName,
458
+ attrName: diagnostic.attrName,
459
+ hint: diagnostic.hint,
460
+ plugin: pluginName,
461
+ validator: validatorName,
462
+ }));
463
+ }
464
+
465
+ function buildPluginPromptMessages(result, fallbackText) {
466
+ if (result && typeof result === 'object' && Array.isArray(result.messages)) {
467
+ return result;
468
+ }
469
+ return {
470
+ messages: [{
471
+ role: 'user',
472
+ content: {
473
+ type: 'text',
474
+ text: typeof result === 'string' ? result : fallbackText,
475
+ },
476
+ }],
477
+ };
478
+ }
479
+
480
+ function buildPluginHandlerContext(plugin, loadJson, loadText) {
481
+ return {
482
+ plugin: { name: plugin.name, version: plugin.version },
483
+ helpers: {
484
+ loadJsonData: loadJson,
485
+ loadTextData: loadText,
486
+ buildJsonToolResponse,
487
+ normalizePrefix,
488
+ withPrefix,
489
+ toCanonicalTagName,
490
+ },
491
+ };
492
+ }
493
+
494
+ function buildPluginResourceContents(resource, result) {
495
+ if (result && typeof result === 'object' && Array.isArray(result.contents)) {
496
+ return {
497
+ ...result,
498
+ contents: result.contents.map((item) => ({
499
+ ...item,
500
+ uri: String(item.uri),
501
+ })),
502
+ };
503
+ }
504
+
505
+ const mimeType = resource.mimeType ?? (typeof result === 'string' || typeof resource.text === 'string'
506
+ ? 'text/plain'
507
+ : 'application/json');
508
+ const text = typeof result === 'string'
509
+ ? result
510
+ : typeof resource.text === 'string'
511
+ ? resource.text
512
+ : JSON.stringify(
513
+ Object.prototype.hasOwnProperty.call(resource, 'payload') ? resource.payload : (result ?? {}),
514
+ null,
515
+ 2,
516
+ );
517
+
518
+ return {
519
+ contents: [{
520
+ uri: String(resource.uri),
521
+ mimeType,
522
+ text,
523
+ }],
524
+ };
525
+ }
526
+
527
+ function buildPluginResourceErrorContents(resource, plugin, error) {
528
+ const message = error instanceof Error ? error.message : String(error);
529
+ return {
530
+ contents: [{
531
+ uri: String(resource.uri),
532
+ mimeType: 'text/plain',
533
+ text: `Plugin resource failed (${plugin.name}/${resource.name}): ${message}`,
534
+ }],
535
+ };
536
+ }
537
+
538
+ function normalizePromptArgsSchema(schema) {
539
+ if (!schema) return undefined;
540
+ if (typeof schema === 'object' && !Array.isArray(schema)) {
541
+ if (schema.shape && typeof schema.shape === 'object') return schema.shape;
542
+ if (schema._def?.shape) {
543
+ const shape = typeof schema._def.shape === 'function' ? schema._def.shape() : schema._def.shape;
544
+ if (shape && typeof shape === 'object') return shape;
545
+ }
546
+ return schema;
547
+ }
548
+ return undefined;
549
+ }
550
+
551
+ function buildPluginResourceTemplateConfig(resourceTemplate) {
552
+ const config = {};
553
+ if (Array.isArray(resourceTemplate.list)) {
554
+ config.list = async () => ({
555
+ resources: resourceTemplate.list.map((uri) => ({
556
+ uri,
557
+ name: resourceTemplate.name,
558
+ description: resourceTemplate.description,
559
+ })),
560
+ });
561
+ }
562
+ if (resourceTemplate.complete && typeof resourceTemplate.complete === 'object') {
563
+ config.complete = Object.fromEntries(
564
+ Object.entries(resourceTemplate.complete).map(([key, values]) => [
565
+ key,
566
+ async (input) => {
567
+ const query = String(input ?? '').trim().toLowerCase();
568
+ return (Array.isArray(values) ? values : [])
569
+ .map((value) => String(value))
570
+ .filter((value) => value.toLowerCase().startsWith(query));
571
+ },
572
+ ]),
573
+ );
574
+ }
575
+ return config;
576
+ }
577
+
149
578
  function buildFullPageHtmlFromImportMap({ html, title, importMapEntries, dir = 'vendor-runtime', lang = 'ja' }) {
150
579
  const importMapJson = JSON.stringify({ imports: importMapEntries }, null, 2);
151
580
  return [
@@ -200,6 +629,192 @@ export function registerAll(context) {
200
629
 
201
630
  const VENDOR_DIR = 'vendor-runtime';
202
631
  const PREFIX_STRIP_RE = /^[^-]+-/;
632
+ let cachedSkillsRegistry = null;
633
+
634
+ async function loadSkillsRegistrySafe() {
635
+ if (cachedSkillsRegistry !== null) return cachedSkillsRegistry;
636
+ try {
637
+ const registry = await loadJsonData('skills-registry.json');
638
+ cachedSkillsRegistry = registry;
639
+ return registry;
640
+ } catch {
641
+ cachedSkillsRegistry = undefined;
642
+ return undefined;
643
+ }
644
+ }
645
+
646
+ async function collectMarkupDiagnostics({ filePath, text, prefix }) {
647
+ const {
648
+ validateTextAgainstCem,
649
+ detectTokenMisuseInInlineStyles,
650
+ detectAccessibilityMisuseInMarkup,
651
+ detectEnumValueMisuse,
652
+ detectInvalidSlotName,
653
+ detectMissingRequiredAttributes,
654
+ detectDuplicateIdsInMarkup,
655
+ detectOrphanedChildComponents,
656
+ detectEmptyInteractiveElement,
657
+ detectNonLowercaseAttributes,
658
+ detectCdnReferences,
659
+ detectMissingRuntimeScaffold,
660
+ } = await loadValidator();
661
+
662
+ const p = normalizePrefix(prefix);
663
+ let cemIndex = canonicalCemIndex;
664
+ let enumMap = canonicalEnumMap;
665
+ let slotMap = canonicalSlotMap;
666
+ if (p !== CANONICAL_PREFIX) {
667
+ cemIndex = mergeWithPrefixed(canonicalCemIndex, p);
668
+ enumMap = mergeWithPrefixed(canonicalEnumMap, p);
669
+ slotMap = mergeWithPrefixed(canonicalSlotMap, p);
670
+ }
671
+
672
+ const cemDiagnostics = validateTextAgainstCem({
673
+ filePath,
674
+ text,
675
+ cem: cemIndex,
676
+ severity: {
677
+ unknownElement: 'error',
678
+ unknownAttribute: 'warning',
679
+ },
680
+ });
681
+
682
+ const enumDiagnostics = detectEnumValueMisuse({
683
+ filePath,
684
+ text,
685
+ enumMap,
686
+ severity: 'error',
687
+ });
688
+
689
+ const tokenMisuseDiagnostics = detectTokenMisuseInInlineStyles({
690
+ filePath,
691
+ text,
692
+ valueToToken: tokenSuggestionMap,
693
+ severity: 'warning',
694
+ });
695
+
696
+ const cemTagNames = new Set(cemIndex.keys());
697
+ const accessibilityDiagnostics = detectAccessibilityMisuseInMarkup({
698
+ filePath,
699
+ text,
700
+ severity: 'error',
701
+ cemTagNames,
702
+ }).map((diagnostic) => ({
703
+ ...diagnostic,
704
+ severity: ACCESSIBILITY_WARNING_CODES.has(diagnostic.code) ? 'warning' : diagnostic.severity,
705
+ }));
706
+
707
+ const slotDiagnostics = detectInvalidSlotName({
708
+ filePath,
709
+ text,
710
+ slotMap,
711
+ severity: 'error',
712
+ });
713
+
714
+ const requiredAttrDiagnostics = detectMissingRequiredAttributes({
715
+ filePath,
716
+ text,
717
+ prefix: p,
718
+ severity: 'error',
719
+ });
720
+
721
+ const duplicateIdDiagnostics = detectDuplicateIdsInMarkup({
722
+ filePath,
723
+ text,
724
+ severity: 'error',
725
+ });
726
+
727
+ const orphanDiagnostics = detectOrphanedChildComponents({
728
+ filePath,
729
+ text,
730
+ prefix: p,
731
+ severity: 'warning',
732
+ });
733
+
734
+ const emptyInteractiveDiagnostics = detectEmptyInteractiveElement({
735
+ filePath,
736
+ text,
737
+ prefix: p,
738
+ severity: 'warning',
739
+ });
740
+
741
+ const lowercaseDiagnostics = detectNonLowercaseAttributes({
742
+ filePath,
743
+ text,
744
+ cem: cemIndex,
745
+ severity: 'warning',
746
+ });
747
+
748
+ const cdnDiagnostics = detectCdnReferences({
749
+ filePath,
750
+ text,
751
+ severity: 'warning',
752
+ });
753
+
754
+ const scaffoldDiagnostics = detectMissingRuntimeScaffold({
755
+ filePath,
756
+ text,
757
+ severity: 'warning',
758
+ });
759
+
760
+ const allRawDiagnostics = [
761
+ ...cemDiagnostics,
762
+ ...enumDiagnostics,
763
+ ...slotDiagnostics,
764
+ ...requiredAttrDiagnostics,
765
+ ...duplicateIdDiagnostics,
766
+ ...orphanDiagnostics,
767
+ ...emptyInteractiveDiagnostics,
768
+ ...lowercaseDiagnostics,
769
+ ...tokenMisuseDiagnostics,
770
+ ...accessibilityDiagnostics,
771
+ ...cdnDiagnostics,
772
+ ...scaffoldDiagnostics,
773
+ ];
774
+
775
+ for (const plugin of plugins) {
776
+ const validators = Array.isArray(plugin.validators) ? plugin.validators : [];
777
+ for (const validator of validators) {
778
+ try {
779
+ const pluginResult = await validator.handler(
780
+ { filePath, text, prefix: p },
781
+ buildPluginHandlerContext(plugin, loadJson, loadText),
782
+ );
783
+ allRawDiagnostics.push(
784
+ ...normalizePluginValidatorDiagnostics(pluginResult, {
785
+ pluginName: plugin.name,
786
+ validatorName: validator.name,
787
+ filePath,
788
+ }),
789
+ );
790
+ } catch (error) {
791
+ allRawDiagnostics.push({
792
+ file: filePath,
793
+ severity: 'warning',
794
+ code: 'pluginValidatorRuntimeError',
795
+ message: `Plugin validator failed (${plugin.name}/${validator.name}): ${error instanceof Error ? error.message : String(error)}`,
796
+ hint: 'Check the plugin validator implementation.',
797
+ plugin: plugin.name,
798
+ validator: validator.name,
799
+ });
800
+ }
801
+ }
802
+ }
803
+
804
+ return allRawDiagnostics.map((diagnostic) => ({
805
+ file: diagnostic.file,
806
+ range: diagnostic.range,
807
+ severity: diagnostic.severity,
808
+ code: diagnostic.code,
809
+ message: diagnostic.message,
810
+ tagName: diagnostic.tagName,
811
+ attrName: diagnostic.attrName,
812
+ hint: diagnostic.hint,
813
+ suggestion: buildDiagnosticSuggestion({ diagnostic, cemIndex, prefix: p }),
814
+ plugin: diagnostic.plugin,
815
+ validator: diagnostic.validator,
816
+ }));
817
+ }
203
818
 
204
819
  // -----------------------------------------------------------------------
205
820
  // Prompt: figma_to_wcf
@@ -226,6 +841,32 @@ export function registerAll(context) {
226
841
  }),
227
842
  );
228
843
 
844
+ // -----------------------------------------------------------------------
845
+ // Prompt: build_page
846
+ // -----------------------------------------------------------------------
847
+ server.registerPrompt(
848
+ BUILD_PAGE_PROMPT,
849
+ {
850
+ title: 'Build Page',
851
+ description:
852
+ 'Guided prompt for building a no-build HTML page from a pattern or component list.',
853
+ argsSchema: {
854
+ patternId: z.string().optional().describe('Pattern ID (e.g., "search-results", "card-grid"). Use list_patterns to see all.'),
855
+ components: z.string().optional().describe('Comma-separated component IDs if not using a pattern'),
856
+ userIntent: z.string().optional().describe('What the page should accomplish'),
857
+ },
858
+ },
859
+ async ({ patternId, components: componentsCsv, userIntent }) => ({
860
+ messages: [{
861
+ role: 'user',
862
+ content: {
863
+ type: 'text',
864
+ text: buildBuildPagePromptText({ patternId, components: componentsCsv, userIntent, detectedPrefix, installRegistry, patterns }),
865
+ },
866
+ }],
867
+ }),
868
+ );
869
+
229
870
  // -----------------------------------------------------------------------
230
871
  // Resource: wcf://components
231
872
  // -----------------------------------------------------------------------
@@ -443,6 +1084,10 @@ export function registerAll(context) {
443
1084
  name: FIGMA_TO_WCF_PROMPT,
444
1085
  purpose: 'Figma-to-WCF conversion workflow prompt',
445
1086
  },
1087
+ {
1088
+ name: BUILD_PAGE_PROMPT,
1089
+ purpose: 'Build a no-build HTML page from a pattern or component list',
1090
+ },
446
1091
  ],
447
1092
  availableResources: [
448
1093
  { uri: WCF_RESOURCE_URIS.components, purpose: 'Component catalog snapshot' },
@@ -459,6 +1104,8 @@ export function registerAll(context) {
459
1104
  { name: 'generate_usage_snippet', purpose: 'Minimal HTML usage example' },
460
1105
  { name: 'get_install_recipe', purpose: 'Installation instructions and dependency tree' },
461
1106
  { name: 'validate_markup', purpose: 'Validate HTML against CEM schema' },
1107
+ { name: 'validate_files', purpose: 'Validate multiple markup files and aggregate diagnostics' },
1108
+ { name: 'validate_project', purpose: 'Validate a project directory using include/exclude globs' },
462
1109
  { name: 'generate_full_page_html', purpose: 'Wrap HTML fragment into a complete page with importmap and boot script' },
463
1110
  { name: 'list_patterns', purpose: 'Browse page-level UI composition patterns' },
464
1111
  { name: 'get_pattern_recipe', purpose: 'Full pattern recipe with dependencies and HTML' },
@@ -467,23 +1114,25 @@ export function registerAll(context) {
467
1114
  { name: 'get_design_token_detail', purpose: 'Get details, relationships, and usage examples for one token' },
468
1115
  { name: 'get_accessibility_docs', purpose: 'Search component-level accessibility checklist and WCAG-filtered guidance' },
469
1116
  { name: 'search_guidelines', purpose: 'Search design system guidelines and best practices' },
1117
+ { name: 'search_design_system_knowledge', purpose: 'Search across components, patterns, guidelines, tokens, and skills' },
470
1118
  { name: 'get_component_selector_guide', purpose: 'Component selection guide by category and use case' },
471
1119
  ],
472
1120
  recommendedWorkflow: [
473
1121
  '1. get_design_system_overview → understand components, patterns, tokens, and IDE setup templates',
474
1122
  '2. figma_to_wcf (optional) → bootstrap the Figma-to-WCF tool sequence',
475
- '3. wcf://components and wcf://tokens resources preload catalog/token context',
476
- '4. search_guidelinesfind relevant guidelines',
477
- '5. get_design_tokensget correct token values',
478
- '6. get_design_token_detailinspect one token with references/referencedBy and usage examples',
479
- '7. get_accessibility_docsfetch component-level accessibility checklist',
480
- '8. list_components (category/query + pagination) shortlist components',
481
- '9. search_icons (optional) → find icon names quickly',
482
- '10. get_component_apicheck attributes, slots, events, CSS parts',
483
- '11. generate_usage_snippet or get_pattern_recipe get code',
484
- '12. validate_markup verify your HTML and use suggestions to self-correct',
485
- '13. generate_full_page_htmlwrap fragment into a complete preview-ready page',
486
- '14. get_install_recipeget import/install instructions',
1123
+ '3. search_design_system_knowledge → do a broad first-pass search across components, patterns, tokens, guidelines, and skills',
1124
+ '4. wcf://components and wcf://tokens resources preload catalog/token context',
1125
+ '5. search_guidelinesfind relevant guidelines',
1126
+ '6. get_design_tokensget correct token values',
1127
+ '7. get_design_token_detailinspect one token with references/referencedBy and usage examples',
1128
+ '8. get_accessibility_docs fetch component-level accessibility checklist',
1129
+ '9. list_components (category/query + pagination) → shortlist components',
1130
+ '10. search_icons (optional) find icon names quickly',
1131
+ '11. get_component_api check attributes, slots, events, CSS parts',
1132
+ '12. generate_usage_snippet or get_pattern_recipe get code',
1133
+ '13. validate_markup / validate_files / validate_project verify your HTML and use suggestions to self-correct',
1134
+ '14. generate_full_page_htmlwrap fragment into a complete preview-ready page',
1135
+ '15. get_install_recipe → get import/install instructions',
487
1136
  ],
488
1137
  experimental: {
489
1138
  plugins: {
@@ -631,7 +1280,7 @@ export function registerAll(context) {
631
1280
 
632
1281
  if (!decl) {
633
1282
  const identifier = component || tagName || className || '';
634
- return buildComponentNotFoundError(identifier, indexes, p);
1283
+ return buildComponentNotFoundError(identifier, indexes, p, installRegistry);
635
1284
  }
636
1285
 
637
1286
  const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
@@ -684,7 +1333,7 @@ export function registerAll(context) {
684
1333
  const decl = resolved?.decl;
685
1334
 
686
1335
  if (!decl) {
687
- return buildComponentNotFoundError(component, indexes, p);
1336
+ return buildComponentNotFoundError(component, indexes, p, installRegistry);
688
1337
  }
689
1338
 
690
1339
  const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
@@ -717,10 +1366,7 @@ export function registerAll(context) {
717
1366
  const decl = resolved?.decl;
718
1367
 
719
1368
  if (!decl) {
720
- return {
721
- content: [{ type: 'text', text: `Component not found: ${component}` }],
722
- isError: true,
723
- };
1369
+ return buildComponentNotFoundError(component, indexes, p, installRegistry);
724
1370
  }
725
1371
 
726
1372
  const canonicalTag = typeof decl.tagName === 'string' ? decl.tagName.toLowerCase() : undefined;
@@ -798,144 +1444,201 @@ export function registerAll(context) {
798
1444
  },
799
1445
  },
800
1446
  async ({ html, prefix }) => {
801
- const {
802
- collectCemCustomElements,
803
- validateTextAgainstCem,
804
- detectTokenMisuseInInlineStyles,
805
- detectAccessibilityMisuseInMarkup,
806
- buildEnumAttributeMap,
807
- detectEnumValueMisuse,
808
- buildSlotNameMap,
809
- detectInvalidSlotName,
810
- detectMissingRequiredAttributes,
811
- detectOrphanedChildComponents,
812
- detectEmptyInteractiveElement,
813
- detectNonLowercaseAttributes,
814
- detectCdnReferences,
815
- detectMissingRuntimeScaffold,
816
- } = await loadValidator();
817
-
818
- const p = normalizePrefix(prefix);
819
- let cemIndex = canonicalCemIndex;
820
- let enumMap = canonicalEnumMap;
821
- let slotMap = canonicalSlotMap;
822
- if (p !== CANONICAL_PREFIX) {
823
- cemIndex = mergeWithPrefixed(canonicalCemIndex, p);
824
- enumMap = mergeWithPrefixed(canonicalEnumMap, p);
825
- slotMap = mergeWithPrefixed(canonicalSlotMap, p);
826
- }
827
-
828
- const cemDiagnostics = validateTextAgainstCem({
1447
+ const diagnostics = await collectMarkupDiagnostics({
829
1448
  filePath: '<markup>',
830
1449
  text: html,
831
- cem: cemIndex,
832
- severity: {
833
- unknownElement: 'error',
834
- unknownAttribute: 'warning',
835
- },
1450
+ prefix,
836
1451
  });
1452
+ return buildJsonToolResponse({ diagnostics });
1453
+ },
1454
+ );
837
1455
 
838
- const enumDiagnostics = detectEnumValueMisuse({
839
- filePath: '<markup>',
840
- text: html,
841
- enumMap,
842
- severity: 'error',
843
- });
1456
+ // -----------------------------------------------------------------------
1457
+ // Tool: validate_files
1458
+ // -----------------------------------------------------------------------
1459
+ server.registerTool(
1460
+ 'validate_files',
1461
+ {
1462
+ description:
1463
+ 'Validate multiple markup files in one call. When: checking a page, template set, or small project for design-system issues. Returns: per-file diagnostics plus aggregate counts. After: fix the reported files or re-run validate_markup on a specific snippet.',
1464
+ inputSchema: {
1465
+ files: z.array(z.object({
1466
+ path: z.string().min(1).describe('File path label. If content is omitted, this path is read from disk.'),
1467
+ content: z.string().optional().describe('Optional markup content. When present, skips disk read.'),
1468
+ })).min(1).max(50),
1469
+ prefix: z.string().optional(),
1470
+ },
1471
+ },
1472
+ async ({ files, prefix }) => {
1473
+ const perFile = [];
1474
+ const fileErrors = [];
1475
+
1476
+ for (const entry of files) {
1477
+ const filePath = String(entry?.path ?? '').trim();
1478
+ if (!filePath) continue;
1479
+
1480
+ let text;
1481
+ if (typeof entry?.content === 'string') {
1482
+ text = entry.content;
1483
+ } else {
1484
+ try {
1485
+ text = await fs.readFile(filePath, 'utf8');
1486
+ } catch (error) {
1487
+ fileErrors.push({
1488
+ path: filePath,
1489
+ message: error instanceof Error ? error.message : String(error),
1490
+ });
1491
+ continue;
1492
+ }
1493
+ }
844
1494
 
845
- const tokenMisuseDiagnostics = detectTokenMisuseInInlineStyles({
846
- filePath: '<markup>',
847
- text: html,
848
- valueToToken: tokenSuggestionMap,
849
- severity: 'warning',
850
- });
1495
+ const diagnostics = await collectMarkupDiagnostics({
1496
+ filePath,
1497
+ text,
1498
+ prefix,
1499
+ });
1500
+ perFile.push({
1501
+ path: filePath,
1502
+ counts: summarizeDiagnostics(diagnostics),
1503
+ diagnostics,
1504
+ });
1505
+ }
851
1506
 
852
- const cemTagNames = new Set(cemIndex.keys());
853
- const accessibilityDiagnostics = detectAccessibilityMisuseInMarkup({
854
- filePath: '<markup>',
855
- text: html,
856
- severity: 'error',
857
- cemTagNames,
858
- }).map((diagnostic) => ({
859
- ...diagnostic,
860
- severity: ACCESSIBILITY_WARNING_CODES.has(diagnostic.code) ? 'warning' : diagnostic.severity,
861
- }));
1507
+ if (perFile.length === 0 && fileErrors.length > 0) {
1508
+ return buildJsonToolErrorResponse({
1509
+ error: {
1510
+ code: 'FILE_READ_ERROR',
1511
+ message: 'No files could be validated.',
1512
+ },
1513
+ fileErrors,
1514
+ });
1515
+ }
862
1516
 
863
- const slotDiagnostics = detectInvalidSlotName({
864
- filePath: '<markup>',
865
- text: html,
866
- slotMap,
867
- severity: 'error',
868
- });
1517
+ const allDiagnostics = perFile.flatMap((file) => file.diagnostics);
1518
+ const counts = summarizeDiagnostics(allDiagnostics);
869
1519
 
870
- const requiredAttrDiagnostics = detectMissingRequiredAttributes({
871
- filePath: '<markup>',
872
- text: html,
873
- prefix: p,
874
- severity: 'error',
1520
+ return buildJsonToolResponse({
1521
+ summary: {
1522
+ filesRequested: files.length,
1523
+ filesValidated: perFile.length,
1524
+ fileErrorCount: fileErrors.length,
1525
+ filesWithErrors: perFile.filter((file) => file.counts.errorCount > 0).length,
1526
+ filesWithWarnings: perFile.filter((file) => file.counts.warningCount > 0).length,
1527
+ ...counts,
1528
+ },
1529
+ fileErrors,
1530
+ files: perFile,
875
1531
  });
1532
+ },
1533
+ );
876
1534
 
877
- const orphanDiagnostics = detectOrphanedChildComponents({
878
- filePath: '<markup>',
879
- text: html,
880
- prefix: p,
881
- severity: 'warning',
882
- });
1535
+ // -----------------------------------------------------------------------
1536
+ // Tool: validate_project
1537
+ // -----------------------------------------------------------------------
1538
+ server.registerTool(
1539
+ 'validate_project',
1540
+ {
1541
+ description:
1542
+ 'Validate a project directory with include/exclude globs. When: checking a template folder, static site, or small app slice for design-system issues. Returns: matched files, file-level diagnostics, and aggregate counts. After: narrow down with validate_files or validate_markup for targeted fixes.',
1543
+ inputSchema: {
1544
+ root: z.string().min(1).describe('Project or template root directory to scan'),
1545
+ include: z.array(z.string()).optional().describe('Glob patterns to include (default: **/*.html, **/*.htm, **/*.njk, **/*.liquid, **/*.astro, **/*.twig, **/*.hbs)'),
1546
+ exclude: z.array(z.string()).optional().describe('Glob patterns to exclude'),
1547
+ maxFiles: z.number().int().min(1).max(500).optional().describe('Maximum files to validate (default: 200)'),
1548
+ prefix: z.string().optional(),
1549
+ },
1550
+ },
1551
+ async ({ root, include, exclude, maxFiles, prefix }) => {
1552
+ const rootDir = path.resolve(String(root ?? '').trim());
1553
+ const includePatterns = Array.isArray(include) && include.length > 0
1554
+ ? include
1555
+ : ['**/*.html', '**/*.htm', '**/*.njk', '**/*.liquid', '**/*.astro', '**/*.twig', '**/*.hbs'];
1556
+ const excludePatterns = Array.isArray(exclude) && exclude.length > 0
1557
+ ? exclude
1558
+ : ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/coverage/**'];
1559
+ const limit = Number.isInteger(maxFiles) ? maxFiles : 200;
1560
+
1561
+ let allFiles;
1562
+ try {
1563
+ allFiles = await walkProjectFiles(rootDir);
1564
+ } catch (error) {
1565
+ return buildJsonToolErrorResponse({
1566
+ error: {
1567
+ code: 'PROJECT_READ_ERROR',
1568
+ message: error instanceof Error ? error.message : String(error),
1569
+ },
1570
+ root: rootDir,
1571
+ });
1572
+ }
883
1573
 
884
- const emptyInteractiveDiagnostics = detectEmptyInteractiveElement({
885
- filePath: '<markup>',
886
- text: html,
887
- prefix: p,
888
- severity: 'warning',
889
- });
1574
+ const matched = [];
1575
+ for (const absolutePath of allFiles) {
1576
+ const relativePath = path.relative(rootDir, absolutePath).split(path.sep).join('/');
1577
+ const included = includePatterns.some((pattern) => matchesGlobPattern(relativePath, pattern));
1578
+ const excluded = excludePatterns.some((pattern) => matchesGlobPattern(relativePath, pattern));
1579
+ if (!included || excluded) continue;
1580
+ matched.push({ absolutePath, relativePath });
1581
+ if (matched.length >= limit) break;
1582
+ }
890
1583
 
891
- const lowercaseDiagnostics = detectNonLowercaseAttributes({
892
- filePath: '<markup>',
893
- text: html,
894
- cem: cemIndex,
895
- severity: 'warning',
896
- });
1584
+ const files = matched.map((entry) => ({ path: entry.absolutePath }));
1585
+ const result = await (async () => {
1586
+ const perFile = [];
1587
+ const fileErrors = [];
897
1588
 
898
- const cdnDiagnostics = detectCdnReferences({
899
- filePath: '<markup>',
900
- text: html,
901
- severity: 'warning',
902
- });
1589
+ for (const entry of matched) {
1590
+ let text;
1591
+ try {
1592
+ text = await fs.readFile(entry.absolutePath, 'utf8');
1593
+ } catch (error) {
1594
+ fileErrors.push({
1595
+ path: entry.absolutePath,
1596
+ message: error instanceof Error ? error.message : String(error),
1597
+ });
1598
+ continue;
1599
+ }
903
1600
 
904
- const scaffoldDiagnostics = detectMissingRuntimeScaffold({
905
- filePath: '<markup>',
906
- text: html,
907
- severity: 'warning',
908
- });
1601
+ const diagnostics = await collectMarkupDiagnostics({
1602
+ filePath: entry.absolutePath,
1603
+ text,
1604
+ prefix,
1605
+ });
1606
+ perFile.push({
1607
+ path: entry.absolutePath,
1608
+ relativePath: entry.relativePath,
1609
+ counts: summarizeDiagnostics(diagnostics),
1610
+ diagnostics,
1611
+ });
1612
+ }
909
1613
 
910
- const allRawDiagnostics = [
911
- ...cemDiagnostics,
912
- ...enumDiagnostics,
913
- ...slotDiagnostics,
914
- ...requiredAttrDiagnostics,
915
- ...orphanDiagnostics,
916
- ...emptyInteractiveDiagnostics,
917
- ...lowercaseDiagnostics,
918
- ...tokenMisuseDiagnostics,
919
- ...accessibilityDiagnostics,
920
- ...cdnDiagnostics,
921
- ...scaffoldDiagnostics,
922
- ];
923
- const diagnostics = allRawDiagnostics.map((d) => {
924
- const suggestion = buildDiagnosticSuggestion({ diagnostic: d, cemIndex, prefix: p });
1614
+ const allDiagnostics = perFile.flatMap((file) => file.diagnostics);
925
1615
  return {
926
- file: d.file,
927
- range: d.range,
928
- severity: d.severity,
929
- code: d.code,
930
- message: d.message,
931
- tagName: d.tagName,
932
- attrName: d.attrName,
933
- hint: d.hint,
934
- suggestion,
1616
+ summary: {
1617
+ root: rootDir,
1618
+ filesScanned: allFiles.length,
1619
+ filesMatched: matched.length,
1620
+ filesValidated: perFile.length,
1621
+ fileErrorCount: fileErrors.length,
1622
+ truncated: matched.length >= limit && allFiles.length > matched.length,
1623
+ ...summarizeDiagnostics(allDiagnostics),
1624
+ },
1625
+ fileErrors,
1626
+ files: perFile,
935
1627
  };
936
- });
1628
+ })();
937
1629
 
938
- return buildJsonToolResponse({ diagnostics });
1630
+ if (result.files.length === 0 && result.fileErrors.length > 0) {
1631
+ return buildJsonToolErrorResponse({
1632
+ error: {
1633
+ code: 'PROJECT_VALIDATION_EMPTY',
1634
+ message: 'No project files could be validated.',
1635
+ },
1636
+ root: rootDir,
1637
+ fileErrors: result.fileErrors,
1638
+ });
1639
+ }
1640
+
1641
+ return buildJsonToolResponse(result);
939
1642
  },
940
1643
  );
941
1644
 
@@ -1188,7 +1891,7 @@ export function registerAll(context) {
1188
1891
  query: z.string().optional()
1189
1892
  .describe('Search token names (partial match)'),
1190
1893
  theme: z.enum(['light', 'dark', 'all']).optional()
1191
- .describe('Theme filter (currently light only; dark/all return an error due to NG-06)'),
1894
+ .describe('Theme filter (currently light only; dark is unsupported and all returns available themes)'),
1192
1895
  },
1193
1896
  },
1194
1897
  async ({ type, category, query, theme }) => {
@@ -1215,7 +1918,7 @@ export function registerAll(context) {
1215
1918
  name: z.string()
1216
1919
  .describe('Token name or css variable (e.g. --color-primary or var(--color-primary))'),
1217
1920
  theme: z.enum(['light', 'dark', 'all']).optional()
1218
- .describe('Theme selector (currently only light is supported due to NG-06)'),
1921
+ .describe('Theme filter (currently light only; dark is unsupported and all returns available themes)'),
1219
1922
  },
1220
1923
  },
1221
1924
  async ({ name, theme }) => {
@@ -1420,10 +2123,277 @@ export function registerAll(context) {
1420
2123
  },
1421
2124
  );
1422
2125
 
2126
+ // -----------------------------------------------------------------------
2127
+ // Tool: search_design_system_knowledge
2128
+ // -----------------------------------------------------------------------
2129
+ server.registerTool(
2130
+ 'search_design_system_knowledge',
2131
+ {
2132
+ description:
2133
+ 'Search across components, patterns, guidelines, tokens, and skills in one call. When: you want a broad first-pass query before choosing a more specific tool. Returns: ranked source-qualified results with source, id, title, description/snippet, and score. After: follow up with the source-specific tool such as get_component_api, get_pattern_recipe, get_design_token_detail, search_guidelines, or the wcf://skills resource.',
2134
+ inputSchema: {
2135
+ query: z.string().describe('Search text for broad design-system discovery'),
2136
+ sources: z.array(z.enum(['components', 'patterns', 'guidelines', 'tokens', 'skills'])).optional()
2137
+ .describe('Optional source filters'),
2138
+ maxResults: z.number().int().min(1).max(50).optional()
2139
+ .describe('Maximum results to return (default: 10)'),
2140
+ prefix: z.string().optional(),
2141
+ },
2142
+ },
2143
+ async ({ query, sources, maxResults, prefix }) => {
2144
+ const requestedSources = Array.isArray(sources) && sources.length > 0
2145
+ ? new Set(sources)
2146
+ : new Set(['components', 'patterns', 'guidelines', 'tokens', 'skills']);
2147
+ const p = normalizePrefix(prefix);
2148
+ const q = String(query ?? '').trim().toLowerCase();
2149
+ const terms = expandQueryWithSynonyms(q).filter(Boolean);
2150
+ const limit = Number.isInteger(maxResults) ? maxResults : 10;
2151
+ const results = [];
2152
+
2153
+ if (requestedSources.has('components')) {
2154
+ const page = buildComponentSummaries(indexes, {
2155
+ query: q,
2156
+ limit: 200,
2157
+ prefix: p,
2158
+ });
2159
+ for (const item of page.items) {
2160
+ const score = scoreSearchFields(q, terms, [
2161
+ { text: item.tagName, weight: 5 },
2162
+ { text: item.className, weight: 4 },
2163
+ { text: item.description, weight: 2 },
2164
+ { text: item.category, weight: 1 },
2165
+ ]);
2166
+ if (score <= 0) continue;
2167
+ results.push({
2168
+ source: 'components',
2169
+ id: item.tagName,
2170
+ title: item.tagName,
2171
+ description: item.description ?? '',
2172
+ metadata: {
2173
+ className: item.className,
2174
+ category: item.category,
2175
+ },
2176
+ score: score + getKnowledgeSourceBoost('components', q, terms),
2177
+ });
2178
+ }
2179
+ }
2180
+
2181
+ if (requestedSources.has('patterns')) {
2182
+ for (const pattern of Object.values(patterns)) {
2183
+ const score = scoreSearchFields(q, terms, [
2184
+ { text: pattern?.id, weight: 5 },
2185
+ { text: pattern?.title, weight: 4 },
2186
+ { text: pattern?.description, weight: 3 },
2187
+ { text: Array.isArray(pattern?.requires) ? pattern.requires.join(' ') : '', weight: 1 },
2188
+ { text: pattern?.behavior, weight: 1 },
2189
+ ]);
2190
+ if (score <= 0) continue;
2191
+ results.push({
2192
+ source: 'patterns',
2193
+ id: String(pattern?.id ?? ''),
2194
+ title: String(pattern?.title ?? pattern?.id ?? ''),
2195
+ description: String(pattern?.description ?? ''),
2196
+ metadata: {
2197
+ requires: Array.isArray(pattern?.requires) ? pattern.requires : [],
2198
+ },
2199
+ score: score + getKnowledgeSourceBoost('patterns', q, terms),
2200
+ });
2201
+ }
2202
+ }
2203
+
2204
+ if (requestedSources.has('guidelines') && Array.isArray(guidelinesIndexData?.documents)) {
2205
+ for (const doc of guidelinesIndexData.documents) {
2206
+ const sections = Array.isArray(doc?.sections) ? doc.sections : [];
2207
+ for (const section of sections) {
2208
+ const score = scoreSearchFields(q, terms, [
2209
+ { text: doc?.title, weight: 3 },
2210
+ { text: doc?.topic, weight: 1 },
2211
+ { text: section?.heading, weight: 4 },
2212
+ { text: Array.isArray(section?.keywords) ? section.keywords.join(' ') : '', weight: 2 },
2213
+ { text: section?.snippet, weight: 2 },
2214
+ { text: section?.body, weight: 1 },
2215
+ ]);
2216
+ if (score <= 0) continue;
2217
+ results.push({
2218
+ source: 'guidelines',
2219
+ id: `${String(doc?.id ?? '')}:${String(section?.heading ?? '')}`,
2220
+ title: String(section?.heading ?? doc?.title ?? ''),
2221
+ description: String(section?.snippet ?? ''),
2222
+ metadata: {
2223
+ documentId: String(doc?.id ?? ''),
2224
+ topic: String(doc?.topic ?? ''),
2225
+ startLine: section?.startLine,
2226
+ },
2227
+ score: score + getKnowledgeSourceBoost('guidelines', q, terms),
2228
+ });
2229
+ }
2230
+ }
2231
+ }
2232
+
2233
+ if (requestedSources.has('tokens') && Array.isArray(designTokensData?.tokens)) {
2234
+ for (const token of designTokensData.tokens) {
2235
+ const score = scoreSearchFields(q, terms, [
2236
+ { text: token?.name, weight: 5 },
2237
+ { text: token?.cssVariable, weight: 4 },
2238
+ { text: token?.type, weight: 2 },
2239
+ { text: token?.category, weight: 2 },
2240
+ { text: token?.value, weight: 1 },
2241
+ ]);
2242
+ if (score <= 0) continue;
2243
+ results.push({
2244
+ source: 'tokens',
2245
+ id: String(token?.name ?? ''),
2246
+ title: String(token?.name ?? ''),
2247
+ description: `${String(token?.type ?? '')}/${String(token?.category ?? '')}: ${String(token?.value ?? '')}`,
2248
+ metadata: {
2249
+ cssVariable: String(token?.cssVariable ?? ''),
2250
+ group: token?.group ?? null,
2251
+ },
2252
+ score: score + getKnowledgeSourceBoost('tokens', q, terms),
2253
+ });
2254
+ }
2255
+ }
2256
+
2257
+ if (requestedSources.has('skills')) {
2258
+ const skillsRegistry = await loadSkillsRegistrySafe();
2259
+ const skills = Array.isArray(skillsRegistry?.skills) ? skillsRegistry.skills : [];
2260
+ for (const skill of skills) {
2261
+ const score = scoreSearchFields(q, terms, [
2262
+ { text: skill?.name, weight: 5 },
2263
+ { text: skill?.description, weight: 3 },
2264
+ { text: Array.isArray(skill?.tags) ? skill.tags.join(' ') : '', weight: 2 },
2265
+ { text: Array.isArray(skill?.clients) ? skill.clients.join(' ') : '', weight: 1 },
2266
+ ]);
2267
+ if (score <= 0) continue;
2268
+ results.push({
2269
+ source: 'skills',
2270
+ id: String(skill?.name ?? ''),
2271
+ title: String(skill?.name ?? ''),
2272
+ description: String(skill?.description ?? ''),
2273
+ metadata: {
2274
+ status: String(skill?.status ?? 'active'),
2275
+ tags: Array.isArray(skill?.tags) ? skill.tags : [],
2276
+ },
2277
+ score: score + getKnowledgeSourceBoost('skills', q, terms),
2278
+ });
2279
+ }
2280
+ }
2281
+
2282
+ results.sort((left, right) =>
2283
+ right.score - left.score ||
2284
+ left.source.localeCompare(right.source) ||
2285
+ left.title.localeCompare(right.title)
2286
+ );
2287
+
2288
+ const topResults = selectKnowledgeResults(results, limit, requestedSources).map((result) => ({
2289
+ ...result,
2290
+ followUp: buildKnowledgeFollowUp(result),
2291
+ }));
2292
+ return buildJsonToolResponse({
2293
+ query,
2294
+ sources: [...requestedSources],
2295
+ totalHits: results.length,
2296
+ results: topResults,
2297
+ suggestions: results.length === 0 ? {
2298
+ alternativeTools: [
2299
+ { tool: 'list_components', hint: 'Browse component inventory directly' },
2300
+ { tool: 'search_guidelines', hint: 'Search guidelines with a narrower query' },
2301
+ { tool: 'get_design_tokens', hint: 'Inspect tokens by type/category instead of free text' },
2302
+ ],
2303
+ } : undefined,
2304
+ });
2305
+ },
2306
+ );
2307
+
1423
2308
  // -----------------------------------------------------------------------
1424
2309
  // Plugin tools registration
1425
2310
  // -----------------------------------------------------------------------
1426
2311
  for (const plugin of plugins) {
2312
+ const pluginPrompts = Array.isArray(plugin.prompts) ? plugin.prompts : [];
2313
+ for (const prompt of pluginPrompts) {
2314
+ server.registerPrompt(
2315
+ prompt.name,
2316
+ {
2317
+ title: prompt.title,
2318
+ description: prompt.description,
2319
+ argsSchema: normalizePromptArgsSchema(prompt.argsSchema),
2320
+ },
2321
+ async (args) => {
2322
+ try {
2323
+ if (typeof prompt.handler === 'function') {
2324
+ const result = await prompt.handler(args, buildPluginHandlerContext(plugin, loadJson, loadText));
2325
+ return buildPluginPromptMessages(result, prompt.text ?? `Prompt from ${plugin.name}`);
2326
+ }
2327
+ return buildPluginPromptMessages(prompt.text ?? `Prompt from ${plugin.name}`, prompt.text ?? '');
2328
+ } catch (error) {
2329
+ const message = error instanceof Error ? error.message : String(error);
2330
+ return buildPluginPromptMessages(`Plugin prompt failed (${prompt.name}): ${message}`, '');
2331
+ }
2332
+ },
2333
+ );
2334
+ }
2335
+
2336
+ const pluginResources = Array.isArray(plugin.resources) ? plugin.resources : [];
2337
+ for (const resource of pluginResources) {
2338
+ server.registerResource(
2339
+ resource.name,
2340
+ resource.uri,
2341
+ {
2342
+ title: resource.title,
2343
+ description: resource.description,
2344
+ mimeType: resource.mimeType ?? undefined,
2345
+ },
2346
+ async () => {
2347
+ try {
2348
+ if (typeof resource.handler === 'function') {
2349
+ const result = await resource.handler(buildPluginHandlerContext(plugin, loadJson, loadText));
2350
+ return buildPluginResourceContents(resource, result);
2351
+ }
2352
+ return buildPluginResourceContents(resource, undefined);
2353
+ } catch (error) {
2354
+ return buildPluginResourceErrorContents(resource, plugin, error);
2355
+ }
2356
+ },
2357
+ );
2358
+ }
2359
+
2360
+ const pluginResourceTemplates = Array.isArray(plugin.resourceTemplates) ? plugin.resourceTemplates : [];
2361
+ for (const resourceTemplate of pluginResourceTemplates) {
2362
+ server.registerResource(
2363
+ resourceTemplate.name,
2364
+ new ResourceTemplate(resourceTemplate.uriTemplate, buildPluginResourceTemplateConfig(resourceTemplate)),
2365
+ {
2366
+ title: resourceTemplate.title,
2367
+ description: resourceTemplate.description,
2368
+ mimeType: resourceTemplate.mimeType ?? undefined,
2369
+ },
2370
+ async (_uri, variables) => {
2371
+ try {
2372
+ if (typeof resourceTemplate.handler === 'function') {
2373
+ const result = await resourceTemplate.handler(
2374
+ { uri: _uri, variables },
2375
+ buildPluginHandlerContext(plugin, loadJson, loadText),
2376
+ );
2377
+ return buildPluginResourceContents(
2378
+ { ...resourceTemplate, uri: _uri },
2379
+ result,
2380
+ );
2381
+ }
2382
+ return buildPluginResourceContents(
2383
+ { ...resourceTemplate, uri: _uri },
2384
+ undefined,
2385
+ );
2386
+ } catch (error) {
2387
+ return buildPluginResourceErrorContents(
2388
+ { ...resourceTemplate, uri: _uri },
2389
+ plugin,
2390
+ error,
2391
+ );
2392
+ }
2393
+ },
2394
+ );
2395
+ }
2396
+
1427
2397
  const pluginTools = Array.isArray(plugin.tools) ? plugin.tools : [];
1428
2398
  for (const tool of pluginTools) {
1429
2399
  server.registerTool(
@@ -1435,17 +2405,7 @@ export function registerAll(context) {
1435
2405
  async (args) => {
1436
2406
  try {
1437
2407
  if (typeof tool.handler === 'function') {
1438
- const result = await tool.handler(args, {
1439
- plugin: { name: plugin.name, version: plugin.version },
1440
- helpers: {
1441
- loadJsonData: loadJson,
1442
- loadTextData: loadText,
1443
- buildJsonToolResponse,
1444
- normalizePrefix,
1445
- withPrefix,
1446
- toCanonicalTagName,
1447
- },
1448
- });
2408
+ const result = await tool.handler(args, buildPluginHandlerContext(plugin, loadJson, loadText));
1449
2409
  if (result !== null && typeof result === 'object' && !Array.isArray(result) && Array.isArray(result.content)) {
1450
2410
  return finalizeToolResult(result);
1451
2411
  }