@expressivecss/mcp-server 0.2.1 → 0.3.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/server.js CHANGED
@@ -7,8 +7,9 @@ import { createHash, randomUUID } from 'node:crypto';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { JSDOM } from 'jsdom';
9
9
  import * as z from 'zod/v4';
10
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
11
11
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
12
13
  import { resolveExpressiveVersion } from './scripts/resolve-version.mjs';
13
14
 
14
15
  const SERVER_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -18,12 +19,23 @@ const SERVER_VERSION = JSON.parse(readFileSync(path.join(SERVER_DIR, 'package.js
18
19
  const CAPABILITIES_BY_SLUG = new Map(CAPABILITY_ROADMAP.entries.map((entry) => [entry.slug, entry]));
19
20
  const COMPONENT_DECISIONS_BY_SLUG = new Map(COMPONENT_DECISIONS.components.map((entry) => [entry.slug, entry]));
20
21
  const DEFAULT_MAX_COMPONENT_RESPONSE_CHARS = 24_000;
22
+ const GUIDANCE_TOOLS = new Set(['setup_expert', 'creative_director', 'page_architect', 'page_arcjitect', 'component_syntax_expert', 'component_catalog']);
23
+ const RESPONSE_BUDGET_SETTING = 'EXPRESSIVECSS_MCP_MAX_RESPONSE_BYTES';
24
+ function configuredResponseBudget(value = '65536') {
25
+ if (!/^\d+$/u.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) {
26
+ throw new Error(`${RESPONSE_BUDGET_SETTING} must be a positive safe integer in decimal bytes.`);
27
+ }
28
+ return Number(value);
29
+ }
21
30
  const DEFAULT_QA_MAX_FILES = 300;
22
31
  const DEFAULT_QA_MAX_MB = 2;
23
32
  const DEFAULT_QA_MAX_TOTAL_MB = 16;
24
33
  const MAX_STATIC_ISSUES = 200;
25
34
  const MAX_STATIC_ISSUES_PER_REQUEST = 1_000;
26
35
  const MAX_STATIC_MARKUP_DELIMITERS = 4_000;
36
+ // Source locations make parse5 copy a parent's children for each text run, so the cost grows with
37
+ // characters times tags. Past this product, semantics findings report 1:1 to stay within the budget.
38
+ const MAX_LOCATED_MARKUP_WORK = 100_000_000;
27
39
  const STATIC_INSPECTION_TIMEOUT_MS = 5_000;
28
40
  const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
29
41
  const MAX_PROJECT_ROOT_CHARS = 4_096;
@@ -61,6 +73,7 @@ function configuredScripts(value) {
61
73
  }
62
74
 
63
75
  const SETTINGS = {
76
+ maxResponseBytes: configuredResponseBudget(process.env[RESPONSE_BUDGET_SETTING]),
64
77
  maxComponentResponseChars: Number(process.env.EXPRESSIVECSS_MCP_MAX_COMPONENT_RESPONSE_CHARS || DEFAULT_MAX_COMPONENT_RESPONSE_CHARS),
65
78
  maxComponentSkips: Number(process.env.EXPRESSIVECSS_MCP_MAX_COMPONENT_SKIPS || 7),
66
79
  qaMaxFiles: Number(process.env.EXPRESSIVECSS_MCP_QA_MAX_FILES || DEFAULT_QA_MAX_FILES),
@@ -96,19 +109,19 @@ const LegacyPatternList = [
96
109
  {
97
110
  id: 'legacy-nav-wrapper',
98
111
  severity: 'high',
99
- description: '`.nav-wrapper` is legacy Materialize structure; use ExpressiveCSS nav components directly.',
112
+ description: '`.nav-wrapper` is retired markup structure; use ExpressiveCSS nav components directly.',
100
113
  pattern: /\bnav-wrapper\b/g,
101
114
  },
102
115
  {
103
116
  id: 'legacy-brand-logo',
104
117
  severity: 'medium',
105
- description: '`.brand-logo` is Materialize-era naming; prefer native layout semantics in ExpressiveCSS pages.',
118
+ description: '`.brand-logo` is retired naming; prefer native layout semantics in ExpressiveCSS pages.',
106
119
  pattern: /\bbrand-logo\b/g,
107
120
  },
108
121
  {
109
122
  id: 'legacy-lever',
110
123
  severity: 'medium',
111
- description: '`.lever` is a Materialize switch token, replace with Expressive switches per component guide.',
124
+ description: '`.lever` is a retired switch token, replace with Expressive switches per component guide.',
112
125
  pattern: /\blever\b/g,
113
126
  },
114
127
  {
@@ -167,7 +180,11 @@ const TOOL_DESCRIPTIONS = {
167
180
  },
168
181
  component_syntax_expert: {
169
182
  stage: 'Component Syntax Expert',
170
- description: 'Return component syntax and constraints, plus scoped Material capability evidence. Request typography, shape, or motion through foundations.',
183
+ description: 'Return complete component rules with detailed syntax by default. Select compact detail to omit prose/examples, includeCapabilities to override component capability detail, sections for documented options/methods, or foundations for typography, shape, or motion. Intentional omissions are disclosed.',
184
+ },
185
+ component_catalog: {
186
+ stage: 'Component Catalogue',
187
+ description: 'List or search bundled components by name, alias or compact description. Search returns bounded, labelled matches; results identify the bundled snapshot and optionally check target-project compatibility.',
171
188
  },
172
189
  quality_inspector: {
173
190
  stage: 'Quality Inspector',
@@ -209,9 +226,19 @@ const pageArchitectSchema = {
209
226
 
210
227
  const syntaxSchema = {
211
228
  projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
229
+ detail: z.enum(['compact', 'detailed']).default('detailed'),
230
+ includeCapabilities: z.boolean().optional(),
212
231
  components: z.array(z.string().max(MAX_COMPONENT_NAME_CHARS)).max(12).default([]),
213
232
  foundations: z.array(z.enum(['typography', 'shape', 'motion'])).max(3).default([]),
233
+ sections: z.array(z.enum(['options', 'methods'])).max(2).refine((sections) => new Set(sections).size === sections.length, 'Request each section at most once').default([]),
234
+ workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
235
+ };
236
+
237
+ const catalogSchema = {
238
+ projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
214
239
  workflowId: z.string().max(MAX_WORKFLOW_ID_CHARS).optional(),
240
+ query: z.string().max(256).trim().min(1).optional(),
241
+ limit: z.number().int().min(1).max(50).optional(),
215
242
  };
216
243
 
217
244
  const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
@@ -234,6 +261,40 @@ const qualityOutputSchema = stageOutputSchema.extend({
234
261
  status: z.enum(['pass', 'warn', 'blocked', 'needs_fix']),
235
262
  inspectionEvidence: inspectionEvidenceSchema.optional(), // Disabled tools return the shared blocked envelope.
236
263
  });
264
+ const retryRequestSchema = z.discriminatedUnion('name', [
265
+ ['setup_expert', setupSchema], ['creative_director', creativeSchema],
266
+ ['page_architect', pageArchitectSchema], ['page_arcjitect', pageArchitectSchema],
267
+ ['component_syntax_expert', syntaxSchema], ['component_catalog', catalogSchema],
268
+ ].map(([name, schema]) => z.object({ name: z.literal(name), arguments: z.object(schema).strict() })));
269
+ const responseBudgetSchema = z.object({
270
+ maxBytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
271
+ delivery: z.enum(['complete', 'partial', 'error']),
272
+ omissions: z.array(z.object({
273
+ unit: z.enum(['field', 'component', 'foundation', 'entry', 'suggestion', 'architecture', 'request']),
274
+ index: z.number().int().nonnegative().optional(),
275
+ requested: z.string().optional(), slug: z.string().optional(),
276
+ field: z.enum(['contract', 'syntax', 'capability', 'options', 'methods']).optional(),
277
+ reason: z.literal('byte-budget'),
278
+ recovery: z.number().int().nonnegative(),
279
+ })),
280
+ recoveries: z.array(z.discriminatedUnion('action', [
281
+ z.object({ action: z.literal('retry'), request: retryRequestSchema }),
282
+ z.object({ action: z.literal('increase-budget'), configuration: z.literal(RESPONSE_BUDGET_SETTING) }),
283
+ ])),
284
+ });
285
+ const guidanceOutputSchema = stageOutputSchema.extend({ responseBudget: responseBudgetSchema });
286
+ const syntaxOutputSchema = guidanceOutputSchema.extend({
287
+ // Disabled tools return only the shared blocked envelope.
288
+ detail: z.enum(['compact', 'detailed']).optional(),
289
+ includeCapabilities: z.boolean().optional(),
290
+ found: z.array(z.looseObject({
291
+ requestIndex: z.number().int().nonnegative(),
292
+ omittedFields: z.array(z.object({
293
+ field: z.enum(['contract', 'syntax', 'options', 'methods', 'capability']),
294
+ reason: z.enum(['compact-detail', 'not-requested', 'length-limit', 'byte-budget']),
295
+ })),
296
+ })).optional(),
297
+ });
237
298
  const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
238
299
 
239
300
  const inspectSchema = {
@@ -285,13 +346,27 @@ function normalizeForMatch(value) {
285
346
 
286
347
  function extractSection(text, heading) {
287
348
  const marker = `#### ${heading}`;
288
- const start = text.indexOf(marker);
289
- if (start === -1) {
290
- return '';
349
+ const lines = text.split('\n');
350
+ let start = -1;
351
+ let fence = null;
352
+ for (let index = 0; index < lines.length; index += 1) {
353
+ const line = lines[index];
354
+ const delimiter = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/u);
355
+ if (fence) {
356
+ if (delimiter && delimiter[1][0] === fence[0] && delimiter[1].length >= fence.length && !delimiter[2].trim()) fence = null;
357
+ continue;
358
+ }
359
+ if (delimiter) {
360
+ fence = delimiter[1];
361
+ continue;
362
+ }
363
+ if (start === -1) {
364
+ if (line.trim() === marker) start = index + 1;
365
+ } else if (/^ {0,3}#{1,4}(?:\s|$)/u.test(line)) {
366
+ return lines.slice(start, index).join('\n').trim();
367
+ }
291
368
  }
292
- const body = text.slice(start + marker.length);
293
- const next = body.indexOf('\n#### ');
294
- return (next === -1 ? body : body.slice(0, next)).trim();
369
+ return start === -1 ? '' : lines.slice(start).join('\n').trim();
295
370
  }
296
371
 
297
372
  function extractTitle(text) {
@@ -328,16 +403,116 @@ function clampText(input, maxChars) {
328
403
  return `${text.slice(0, Math.max(0, maxChars - 40))}…(truncated)`;
329
404
  }
330
405
 
331
- function toToolResult(payload) {
406
+ function serializeToolResult(payload) {
332
407
  return {
333
408
  content: [{
334
409
  type: 'text',
335
- text: JSON.stringify(payload, null, 2),
410
+ text: JSON.stringify(payload, null, GUIDANCE_TOOLS.has(payload.stage) ? undefined : 2),
336
411
  }],
337
412
  structuredContent: payload,
413
+ ...(payload.responseBudget?.delivery === 'error' ? { isError: true } : {}),
338
414
  };
339
415
  }
340
416
 
417
+ function toToolResult(payload, args = {}, guidanceContext) {
418
+ const result = () => serializeToolResult(payload);
419
+ if (!GUIDANCE_TOOLS.has(payload.stage)) return result();
420
+ payload.responseBudget = { maxBytes: SETTINGS.maxResponseBytes, delivery: 'complete', omissions: [], recoveries: [] };
421
+ const fits = () => Buffer.byteLength(JSON.stringify(result()), 'utf8') <= SETTINGS.maxResponseBytes;
422
+ if (fits()) return result();
423
+
424
+ const original = structuredClone(payload);
425
+ const context = { workflowId: payload.workflowId, ...(args.projectRoot === undefined ? {} : { projectRoot: args.projectRoot }) };
426
+ const increase = { action: 'increase-budget', configuration: RESPONSE_BUDGET_SETTING };
427
+ const omit = (identity, recovery) => {
428
+ const budget = payload.responseBudget;
429
+ // Share recovery records, especially operator-increase advice, to bound their overhead.
430
+ const key = JSON.stringify(recovery);
431
+ let index = budget.recoveries.findIndex((entry) => JSON.stringify(entry) === key);
432
+ if (index < 0) index = budget.recoveries.push(recovery) - 1;
433
+ budget.omissions.push({ ...identity, reason: 'byte-budget', recovery: index });
434
+ budget.delivery = recovery.action === 'increase-budget' ? 'error' : budget.delivery === 'error' ? 'error' : 'partial';
435
+ payload.status = original.status === 'blocked' || budget.delivery === 'error' ? 'blocked' : 'partial';
436
+ payload.coverageStatus = budget.delivery === 'error' ? 'response-budget-error' : 'partial-response-budget';
437
+ };
438
+ const retry = (arguments_) => ({ action: 'retry', request: retryRequestSchema.parse({ name: payload.stage, arguments: {
439
+ // Catalogue retrieval has no workflow state. Preserve its explicit target without
440
+ // repeating an optional correlation ID for every individually recoverable entry.
441
+ ...(payload.stage === 'component_catalog' ? args.projectRoot === undefined ? {} : { projectRoot: args.projectRoot } : context),
442
+ ...arguments_,
443
+ } }) });
444
+ const recoveryResultFits = (candidate) => {
445
+ candidate.responseBudget = { maxBytes: SETTINGS.maxResponseBytes, delivery: 'complete', omissions: [], recoveries: [] };
446
+ return Buffer.byteLength(JSON.stringify(serializeToolResult(candidate)), 'utf8') <= SETTINGS.maxResponseBytes;
447
+ };
448
+
449
+ if (payload.stage === 'component_syntax_expert' && payload.found) {
450
+ const components = payload.found.map((entry, index) => ({ entry, index: entry.requestIndex ?? index }));
451
+ const syntaxRecovery = (entry, field) => {
452
+ const arguments_ = { components: [entry.slug], detail: field === 'contract' || field === 'syntax' ? 'detailed' : 'compact', includeCapabilities: field === 'capability', sections: ['options', 'methods'].includes(field) ? [field] : [] };
453
+ const recovery = retry(arguments_);
454
+ const candidate = buildSyntaxPayload(guidanceContext.catalog, guidanceContext.version, recovery.request.arguments);
455
+ return recoveryResultFits(candidate) ? recovery : increase;
456
+ };
457
+ for (const field of ['capability', 'contract', 'syntax', 'options', 'methods']) {
458
+ for (const { entry, index } of [...components].reverse()) {
459
+ if (!Object.hasOwn(entry, field)) continue;
460
+ const recovery = syntaxRecovery(original.found.find((row) => row.requestIndex === index) ?? entry, field);
461
+ delete entry[field];
462
+ entry.omittedFields = entry.omittedFields.filter((row) => row.field !== field);
463
+ entry.omittedFields.push({ field, reason: 'byte-budget' });
464
+ entry.omittedFields.sort((a, b) => ['contract', 'syntax', 'options', 'methods', 'capability'].indexOf(a.field) - ['contract', 'syntax', 'options', 'methods', 'capability'].indexOf(b.field));
465
+ omit({ unit: 'field', index, requested: args.components?.[index] ?? entry.slug, slug: entry.slug, field }, recovery);
466
+ if (fits()) return result();
467
+ }
468
+ }
469
+ while (payload.found.length) {
470
+ const entry = payload.found.pop();
471
+ const index = entry.requestIndex;
472
+ omit({ unit: 'component', index, requested: args.components?.[index] ?? entry.slug, slug: entry.slug }, syntaxRecovery(original.found.find((row) => row.requestIndex === index), undefined));
473
+ payload.foundCount = payload.found.length;
474
+ if (fits()) return result();
475
+ }
476
+ while (payload.foundations.length) {
477
+ const index = payload.foundations.length - 1;
478
+ const entry = payload.foundations.pop();
479
+ let recovery = retry({ components: [], foundations: [args.foundations[index]], detail: 'compact', includeCapabilities: false });
480
+ const candidate = buildSyntaxPayload(guidanceContext.catalog, guidanceContext.version, recovery.request.arguments);
481
+ if (!recoveryResultFits(candidate)) recovery = increase;
482
+ omit({ unit: 'foundation', index, requested: args.foundations[index], slug: entry.slug }, recovery);
483
+ if (fits()) return result();
484
+ }
485
+ } else if (payload.stage === 'component_catalog' || payload.stage === 'creative_director') {
486
+ const field = payload.stage === 'component_catalog' ? 'entries' : 'suggestions';
487
+ while (payload[field].length) {
488
+ const index = payload[field].length - 1;
489
+ const entry = payload[field].pop();
490
+ const goal = entry.selectionSource === 'native-pattern' ? 'Choose persistent nonblocking inline feedback.' : `Choose ${entry.slug} for this interface.`;
491
+ let recovery = field === 'entries' ? retry({ query: entry.slug, limit: 1 }) : retry({ goal, maxSuggestions: 1 });
492
+ const candidate = (field === 'entries' ? buildCatalogPayload : buildCreativePayload)(guidanceContext.catalog, guidanceContext.version, recovery.request.arguments);
493
+ if (candidate[field][0]?.slug !== entry.slug || !recoveryResultFits(candidate)) recovery = increase;
494
+ omit({ unit: field === 'entries' ? 'entry' : 'suggestion', ...(field === 'entries' ? {} : { index }), slug: entry.slug }, recovery);
495
+ payload.count = payload[field].length;
496
+ if (field === 'suggestions') { payload.omittedCount += 1; payload.truncated = true; }
497
+ if (fits()) return result();
498
+ }
499
+ } else if (payload.architecture) {
500
+ payload.architecture = null;
501
+ omit({ unit: 'architecture' }, increase);
502
+ if (fits()) return result();
503
+ }
504
+
505
+ // No guidance is delivered by this error. Preserve actual checks and compatibility evidence.
506
+ const essential = ['workflowId', 'stage', 'checksPerformed', 'evidenceSources', 'uncheckedAreas', 'blockedChecks', 'contractCompatibility', 'contractProvenance', 'contractProvenanceDetails', 'contractVersion', 'sourceHash', 'guideSource', 'capabilityEvidence', 'skipped'];
507
+ payload = Object.fromEntries(essential.filter((key) => Object.hasOwn(original, key)).map((key) => [key, original[key]]));
508
+ payload.status = 'blocked';
509
+ payload.coverageStatus = 'response-budget-error';
510
+ payload.message = 'No requested guidance was delivered. Increase the operator response budget and repeat the request.';
511
+ payload.responseBudget = { maxBytes: SETTINGS.maxResponseBytes, delivery: 'error', omissions: [{ unit: 'request', reason: 'byte-budget', recovery: 0 }], recoveries: [increase] };
512
+ // The final transport check converts this into a protocol error if even evidence cannot fit.
513
+ return result();
514
+ }
515
+
341
516
  function buildStagePayload(tool, result, workflowId) {
342
517
  return {
343
518
  workflowId: workflowId || randomUUID().slice(0, 12),
@@ -400,6 +575,8 @@ function parseGuide(file, content) {
400
575
  const contract = extractSection(content, 'Contract');
401
576
  const syntax = extractSection(content, 'Syntax');
402
577
  const rules = extractRules(extractSection(content, 'Rules'));
578
+ const options = extractSection(content, 'Options');
579
+ const methods = extractSection(content, 'Methods');
403
580
  const syntaxCode = extractFirstCodeBlock(syntax);
404
581
  const syntaxLangMatch = syntax.match(/```\s*([a-z0-9+.-]+)/i);
405
582
  const syntaxLanguage = syntaxLangMatch ? syntaxLangMatch[1].toLowerCase() : 'html';
@@ -410,15 +587,17 @@ function parseGuide(file, content) {
410
587
  file,
411
588
  slug,
412
589
  title,
413
- contract: clampText(contract, 1_000),
590
+ contract,
414
591
  syntax: {
415
592
  language: syntaxLanguage,
416
- code: clampText(syntaxCode, Math.max(1_200, Math.floor(SETTINGS.maxComponentResponseChars / 2))),
593
+ code: syntaxCode,
417
594
  },
418
595
  rules: rules.length ? rules : [
419
596
  'Follow the component contract in the full documentation before adding optional attributes.',
420
597
  'Keep runtime-owned state in framework initialization, not in static markup values.',
421
598
  ],
599
+ options: options || null,
600
+ methods: methods || null,
422
601
  sourceUrl: docsMatch ? docsMatch[1] : null,
423
602
  astroSource: repoMatch ? `https://github.com/BaezFJ/ExpressiveCSS/blob/master/docs/src/pages/${repoMatch[1]}.astro` : null,
424
603
  text: `${title}\n${contract}\n${rules.join('\n')}`.toLowerCase(),
@@ -594,13 +773,13 @@ async function loadGuideCatalog(projectRoot) {
594
773
 
595
774
  let provenance;
596
775
 
597
- const guideDir = await resolveGuideDirectory(projectRoot);
776
+ const guideDir = projectRoot === undefined ? null : await resolveGuideDirectory(projectRoot);
598
777
  const dirStat = guideDir ? await stat(guideDir).catch(() => null) : null;
599
- const projectResolution = await resolveExpressiveVersion({
778
+ const projectResolution = projectRoot === undefined ? null : await resolveExpressiveVersion({
600
779
  projectRoot,
601
780
  contractVersion: bundledGuideCache.frameworkVersion,
602
781
  });
603
- const isFrameworkSource = projectResolution.resolutionSource === 'framework-source';
782
+ const isFrameworkSource = projectResolution?.resolutionSource === 'framework-source';
604
783
  if (dirStat?.isDirectory()) {
605
784
  const localContractPath = path.join(projectRoot, 'skills', 'expressivecss', 'references', 'contract.json');
606
785
  const localContractRead = await readInspectionFile(
@@ -647,7 +826,7 @@ async function loadGuideCatalog(projectRoot) {
647
826
 
648
827
  return {
649
828
  generatedAt: new Date().toISOString(),
650
- projectRoot: path.resolve(projectRoot),
829
+ projectRoot: projectRoot === undefined ? null : path.resolve(projectRoot),
651
830
  frameworkVersion: bundledGuideCache.frameworkVersion,
652
831
  sourceHash: bundledGuideCache.sourceHash,
653
832
  provenance,
@@ -879,7 +1058,15 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
879
1058
  const { semantics, frameworkVersion } = loadSemanticsContract();
880
1059
  if (maxIssues <= 0) return markInspectionTruncated([], 'issue limit reached');
881
1060
  if (markupStructureExceedsLimit(snippet)) return markInspectionTruncated([], 'markup structure limit reached');
882
- const { document } = new JSDOM(`<!doctype html><body>${snippet}</body>`).window;
1061
+ const prefix = '<!doctype html><body>';
1062
+ const includeNodeLocations = snippet.length * (snippet.split('<').length - 1) <= MAX_LOCATED_MARKUP_WORK;
1063
+ const dom = new JSDOM(`${prefix}${snippet}</body>`, { includeNodeLocations });
1064
+ const { document } = dom.window;
1065
+ // Elements the parser creates without a start tag have no source offset, so they report the start.
1066
+ const locate = (element) => {
1067
+ const offset = includeNodeLocations ? dom.nodeLocation(element)?.startOffset ?? -1 : -1;
1068
+ return offset >= prefix.length ? lineForMatch(snippet, offset - prefix.length) : { line: 1, column: 1 };
1069
+ };
883
1070
  const issues = [];
884
1071
 
885
1072
  for (const rule of enforcedSemanticRules(semantics)) {
@@ -890,7 +1077,7 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
890
1077
  for (const element of hits) {
891
1078
  const stop = inspectionStopReason(issues, maxIssues, deadline);
892
1079
  if (stop) return markInspectionTruncated(issues, stop);
893
- issues.push(semanticIssue(rule, element, frameworkVersion));
1080
+ issues.push(semanticIssue(rule, element, frameworkVersion, locate(element)));
894
1081
  }
895
1082
  } else if (rule.kind === 'require-attr') {
896
1083
  for (const element of hits) {
@@ -899,7 +1086,7 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
899
1086
  const value = element.getAttribute(rule.attr);
900
1087
  const valid = rule.equals ? value === rule.equals : value !== null && value !== '';
901
1088
  if (!valid) {
902
- issues.push(semanticIssue(rule, element, frameworkVersion));
1089
+ issues.push(semanticIssue(rule, element, frameworkVersion, locate(element)));
903
1090
  }
904
1091
  }
905
1092
  } else if (rule.kind === 'require-accessible-name') {
@@ -907,7 +1094,7 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
907
1094
  const stop = inspectionStopReason(issues, maxIssues, deadline);
908
1095
  if (stop) return markInspectionTruncated(issues, stop);
909
1096
  if (!accessibleName(element, document)) {
910
- issues.push(semanticIssue(rule, element, frameworkVersion));
1097
+ issues.push(semanticIssue(rule, element, frameworkVersion, locate(element)));
911
1098
  }
912
1099
  }
913
1100
  }
@@ -928,7 +1115,7 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
928
1115
  component: 'landmarks',
929
1116
  frameworkVersion,
930
1117
  rule: `Navigation landmarks on one page need distinct names; "${name}" is repeated.`,
931
- location: { line: 1, column: 1 },
1118
+ location: locate(nav),
932
1119
  snippet: nav.outerHTML.slice(0, 140),
933
1120
  });
934
1121
  }
@@ -938,14 +1125,14 @@ function inspectSemanticRules(snippet, maxIssues = MAX_STATIC_ISSUES, deadline =
938
1125
  return issues;
939
1126
  }
940
1127
 
941
- function semanticIssue(rule, element, frameworkVersion) {
1128
+ function semanticIssue(rule, element, frameworkVersion, location) {
942
1129
  return {
943
1130
  id: rule.id,
944
1131
  severity: 'high',
945
1132
  component: rule.component,
946
1133
  frameworkVersion,
947
1134
  rule: rule.message,
948
- location: { line: 1, column: 1 },
1135
+ location,
949
1136
  snippet: element.outerHTML.slice(0, 140),
950
1137
  };
951
1138
  }
@@ -1267,19 +1454,29 @@ function buildPageArchitecture(catalog, pageGoal, components = [], viewportTarge
1267
1454
  };
1268
1455
  }
1269
1456
 
1270
- function summarizeGuide(guide) {
1457
+ function summarizeGuide(guide, { sections, detail, includeCapabilities }) {
1271
1458
  return {
1272
1459
  file: guide.file,
1273
1460
  slug: guide.slug,
1274
1461
  title: guide.title,
1275
1462
  source: guide.sourceUrl,
1276
1463
  docs: guide.astroSource,
1277
- contract: clampText(guide.contract, 900),
1278
- syntax: {
1279
- language: guide.syntax.language,
1280
- example: clampText(guide.syntax.code, 3_000),
1281
- },
1282
- rules: guide.rules.slice(0, 8),
1464
+ ...(detail === 'detailed' ? {
1465
+ contract: clampText(guide.contract, 900),
1466
+ syntax: {
1467
+ language: guide.syntax.language,
1468
+ example: guide.syntax.code,
1469
+ },
1470
+ } : {}),
1471
+ rules: guide.rules,
1472
+ ...(sections.includes('options') ? { options: { status: guide.options ? 'documented' : 'absent', markdown: guide.options } } : {}),
1473
+ ...(sections.includes('methods') ? { methods: { status: guide.methods ? 'documented' : 'absent', markdown: guide.methods } } : {}),
1474
+ omittedFields: [
1475
+ ...(detail === 'compact' ? [{ field: 'contract', reason: 'compact-detail' }, { field: 'syntax', reason: 'compact-detail' }] : []),
1476
+ ...['options', 'methods'].filter((field) => !sections.includes(field)).map((field) => ({ field, reason: 'not-requested' })),
1477
+ ...(!includeCapabilities ? [{ field: 'capability', reason: 'not-requested' }] : []),
1478
+ ...(detail === 'detailed' && guide.contract.length > 900 ? [{ field: 'contract', reason: 'length-limit' }] : []),
1479
+ ].sort((a, b) => ['contract', 'syntax', 'options', 'methods', 'capability'].indexOf(a.field) - ['contract', 'syntax', 'options', 'methods', 'capability'].indexOf(b.field)),
1283
1480
  };
1284
1481
  }
1285
1482
 
@@ -1684,6 +1881,7 @@ const rulesSchemaParsed = z.object(rulesSchema);
1684
1881
  const creativeSchemaParsed = z.object(creativeSchema);
1685
1882
  const architectSchemaParsed = z.object(pageArchitectSchema);
1686
1883
  const syntaxSchemaParsed = z.object(syntaxSchema).refine((value) => value.components.length + value.foundations.length > 0, 'Request at least one component or foundation');
1884
+ const catalogSchemaParsed = z.object(catalogSchema);
1687
1885
  const qualitySchemaParsed = z.object(inspectSchema);
1688
1886
 
1689
1887
  async function setupExpertHandler(args) {
@@ -1803,7 +2001,7 @@ async function setupExpertHandler(args) {
1803
2001
  workflowId,
1804
2002
  );
1805
2003
 
1806
- return toToolResult(payload);
2004
+ return toToolResult(payload, { ...parsed, projectRoot });
1807
2005
  }
1808
2006
 
1809
2007
  async function rulesEnforcerHandler(args) {
@@ -1920,7 +2118,7 @@ async function rulesEnforcerHandler(args) {
1920
2118
  ],
1921
2119
  guidance: {
1922
2120
  alwaysPrefer: 'Read component guides before adding structure.',
1923
- compatibility: 'Avoid Materialize-era selectors and prefer source-of-truth component contracts.',
2121
+ compatibility: 'Avoid retired selectors and prefer source-of-truth component contracts.',
1924
2122
  },
1925
2123
  },
1926
2124
  workflowId,
@@ -1935,10 +2133,14 @@ async function creativeDirectorHandler(args) {
1935
2133
  }
1936
2134
 
1937
2135
  const parsed = creativeSchemaParsed.parse(args);
1938
- const workflowId = parsed.workflowId || randomUUID();
1939
2136
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
1940
2137
  const catalog = await loadGuideCatalog(projectRoot);
1941
2138
  const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2139
+ return toToolResult(buildCreativePayload(catalog, version, parsed), { ...parsed, projectRoot }, { catalog, version });
2140
+ }
2141
+
2142
+ function buildCreativePayload(catalog, version, parsed) {
2143
+ const workflowId = parsed.workflowId || randomUUID();
1942
2144
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
1943
2145
  const contractSafe = version.status === 'match' && !provenanceBlock;
1944
2146
 
@@ -1948,7 +2150,7 @@ async function creativeDirectorHandler(args) {
1948
2150
  : { suggestions: [], truncated: false, omittedCount: 0 };
1949
2151
  const { suggestions, truncated, omittedCount } = candidateResult;
1950
2152
 
1951
- const payload = buildStagePayload(
2153
+ return buildStagePayload(
1952
2154
  'creative_director',
1953
2155
  {
1954
2156
  goal: parsed.goal,
@@ -1986,8 +2188,6 @@ async function creativeDirectorHandler(args) {
1986
2188
  },
1987
2189
  workflowId,
1988
2190
  );
1989
-
1990
- return toToolResult(payload);
1991
2191
  }
1992
2192
 
1993
2193
  async function pageArchitectHandler(args, stage = 'page_architect') {
@@ -2061,7 +2261,7 @@ async function pageArchitectHandler(args, stage = 'page_architect') {
2061
2261
  workflowId,
2062
2262
  );
2063
2263
 
2064
- return toToolResult(payload);
2264
+ return toToolResult(payload, { ...parsed, projectRoot });
2065
2265
  }
2066
2266
 
2067
2267
  async function componentSyntaxExpertHandler(args) {
@@ -2070,10 +2270,15 @@ async function componentSyntaxExpertHandler(args) {
2070
2270
  }
2071
2271
 
2072
2272
  const parsed = syntaxSchemaParsed.parse(args);
2073
- const workflowId = parsed.workflowId || randomUUID();
2074
2273
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2075
2274
  const catalog = await loadGuideCatalog(projectRoot);
2076
2275
  const version = await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2276
+ return toToolResult(buildSyntaxPayload(catalog, version, parsed), { ...parsed, projectRoot }, { catalog, version });
2277
+ }
2278
+
2279
+ function buildSyntaxPayload(catalog, version, parsed) {
2280
+ const includeCapabilities = parsed.includeCapabilities ?? parsed.detail === 'detailed';
2281
+ const workflowId = parsed.workflowId || randomUUID();
2077
2282
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2078
2283
 
2079
2284
  const capabilitySafe = version.status === 'match' && !provenanceBlock && CAPABILITY_ROADMAP.frameworkVersion === catalog.frameworkVersion;
@@ -2081,7 +2286,7 @@ async function componentSyntaxExpertHandler(args) {
2081
2286
  const found = [];
2082
2287
  const missing = [];
2083
2288
 
2084
- for (const component of requested) {
2289
+ for (const [requestIndex, component] of requested.entries()) {
2085
2290
  const guide = findGuideByName(catalog, component, false);
2086
2291
  if (!guide) {
2087
2292
  const skipLimit = Math.min(Math.max(SETTINGS.maxComponentSkips, 1), 20);
@@ -2090,13 +2295,19 @@ async function componentSyntaxExpertHandler(args) {
2090
2295
  nearest: nearestMatches(catalog, component, skipLimit).map((row) => row.slug),
2091
2296
  });
2092
2297
  } else {
2093
- found.push({ ...summarizeGuide(guide), capability: capabilitySafe ? CAPABILITIES_BY_SLUG.get(guide.slug) ?? null : null });
2298
+ found.push({
2299
+ requestIndex,
2300
+ ...summarizeGuide(guide, { ...parsed, includeCapabilities }),
2301
+ ...(includeCapabilities ? { capability: capabilitySafe ? CAPABILITIES_BY_SLUG.get(guide.slug) ?? null : null } : {}),
2302
+ });
2094
2303
  }
2095
2304
  }
2096
2305
 
2097
- const payload = buildStagePayload(
2306
+ return buildStagePayload(
2098
2307
  'component_syntax_expert',
2099
2308
  {
2309
+ detail: parsed.detail,
2310
+ includeCapabilities,
2100
2311
  foundCount: found.length,
2101
2312
  foundations: capabilitySafe ? parsed.foundations.map((slug) => CAPABILITIES_BY_SLUG.get(slug)) : [],
2102
2313
  capabilityEvidence: { status: capabilitySafe ? 'bundled-review-snapshot' : 'blocked', basis: CAPABILITY_ROADMAP.basis, browserRun: capabilitySafe ? CAPABILITY_ROADMAP.browserRun : null },
@@ -2131,8 +2342,74 @@ async function componentSyntaxExpertHandler(args) {
2131
2342
  },
2132
2343
  workflowId,
2133
2344
  );
2345
+ }
2134
2346
 
2135
- return toToolResult(payload);
2347
+ async function componentCatalogHandler(args) {
2348
+ const parsed = catalogSchemaParsed.parse(args);
2349
+ const projectRoot = parsed.projectRoot === undefined ? undefined : path.resolve(parsed.projectRoot);
2350
+ const catalog = await loadGuideCatalog(projectRoot);
2351
+ const version = projectRoot === undefined ? null : await resolveAgainstContract(projectRoot, catalog.frameworkVersion);
2352
+ return toToolResult(buildCatalogPayload(catalog, version, parsed), parsed, { catalog, version });
2353
+ }
2354
+
2355
+ function buildCatalogPayload(catalog, version, parsed) {
2356
+ const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2357
+ let entries = [...catalog.components.values()].map((guide) => {
2358
+ const decision = COMPONENT_DECISIONS_BY_SLUG.get(guide.slug);
2359
+ return {
2360
+ slug: guide.slug,
2361
+ title: guide.title,
2362
+ description: [...(decision?.useWhen ?? []), ...(decision?.jobs ?? [])].find((value) => typeof value === 'string' && value.trim()) ?? null,
2363
+ aliases: decision?.aliases ?? [],
2364
+ runtime: decision?.runtime ?? null,
2365
+ docs: guide.sourceUrl,
2366
+ };
2367
+ }).sort((a, b) => a.slug.localeCompare(b.slug));
2368
+
2369
+ let search = {};
2370
+ if (parsed.query !== undefined) {
2371
+ const query = normalizeForMatch(parsed.query);
2372
+ const tokens = query.split('-').filter(Boolean);
2373
+ const matchOrder = ['exact-name', 'exact-alias', 'heuristic'];
2374
+ entries = entries.flatMap((entry) => {
2375
+ const names = [entry.slug, entry.title].map(normalizeForMatch);
2376
+ const aliases = entry.aliases.map(normalizeForMatch);
2377
+ const fields = [...names, ...aliases, normalizeForMatch(entry.description)];
2378
+ const matchType = query && names.includes(query) ? 'exact-name'
2379
+ : query && aliases.includes(query) ? 'exact-alias'
2380
+ : tokens.length && tokens.every((token) => fields.some((field) => field.includes(token))) ? 'heuristic' : null;
2381
+ return matchType ? [{ ...entry, matchType }] : [];
2382
+ }).sort((a, b) => matchOrder.indexOf(a.matchType) - matchOrder.indexOf(b.matchType) || a.slug.localeCompare(b.slug));
2383
+ const totalMatches = entries.length;
2384
+ const limit = parsed.limit ?? 10;
2385
+ entries = entries.slice(0, limit);
2386
+ const omittedCount = totalMatches - entries.length;
2387
+ search = { query: parsed.query, limit, totalMatches, omittedCount, truncated: omittedCount > 0 };
2388
+ }
2389
+
2390
+ return buildStagePayload('component_catalog', {
2391
+ entries,
2392
+ count: entries.length,
2393
+ ...search,
2394
+ contractVersion: catalog.frameworkVersion,
2395
+ sourceHash: catalog.sourceHash,
2396
+ guideSource: catalog.guideSource,
2397
+ status: (!version || version.status === 'match') && !provenanceBlock ? 'available' : 'blocked',
2398
+ checksPerformed: [parsed.query === undefined ? 'bundled component catalogue listing' : 'bundled component catalogue search', ...(version ? ['target contract resolution', 'contract provenance validation'] : [])],
2399
+ evidenceSources: ['bundled:component-guides.json', 'bundled:component-decisions.json', 'bundled:contract.json', ...(version ? [version.resolutionSource] : [])],
2400
+ uncheckedAreas: [
2401
+ ...(!version ? ['target-project compatibility', 'target-project provenance'] : []),
2402
+ 'rendered component behavior', 'visual hierarchy', 'responsive composition', 'keyboard and assistive-technology behavior',
2403
+ ],
2404
+ contractCompatibility: version?.status ?? 'unknown',
2405
+ contractProvenance: catalog.provenance.status,
2406
+ contractProvenanceDetails: catalog.provenance,
2407
+ coverageStatus: parsed.query === undefined ? 'complete-bundled-catalogue' : search.truncated ? 'partial-search-results' : 'complete-search-results',
2408
+ blockedChecks: [
2409
+ ...(version && version.status !== 'match' ? ['target-version contract checks'] : []),
2410
+ ...(provenanceBlock ? [provenanceBlock] : []),
2411
+ ],
2412
+ }, parsed.workflowId);
2136
2413
  }
2137
2414
 
2138
2415
  async function qualityInspectorHandler(args) {
@@ -2304,6 +2581,105 @@ async function qualityInspectorHandler(args) {
2304
2581
  return toToolResult(payload);
2305
2582
  }
2306
2583
 
2584
+ const COMPONENT_SECTIONS = ['contract', 'syntax', 'rules', 'options', 'methods'];
2585
+
2586
+ function resourceRequiredBytes(result) {
2587
+ // A retry's budget field can need more decimal digits. Include those bytes too.
2588
+ const retry = structuredClone(result);
2589
+ const payload = JSON.parse(retry.contents[0].text);
2590
+ let requiredBytes = Buffer.byteLength(JSON.stringify(retry), 'utf8');
2591
+ for (;;) {
2592
+ payload.responseBudget.maxBytes = requiredBytes;
2593
+ retry.contents[0].text = JSON.stringify(payload);
2594
+ const measured = Buffer.byteLength(JSON.stringify(retry), 'utf8');
2595
+ if (measured <= requiredBytes) return requiredBytes;
2596
+ requiredBytes = measured;
2597
+ }
2598
+ }
2599
+
2600
+ function resourceBudgetError(uri, result, resources) {
2601
+ const requiredBytes = resourceRequiredBytes(result);
2602
+ const data = { uri, maxBytes: SETTINGS.maxResponseBytes, requiredBytes, setting: RESPONSE_BUDGET_SETTING };
2603
+ if (!resources.sections.has(uri)) {
2604
+ return {
2605
+ code: -32001,
2606
+ message: `Response budget cannot fit the complete catalogue resource. Increase ${RESPONSE_BUDGET_SETTING} to at least ${requiredBytes} and restart.`,
2607
+ data,
2608
+ };
2609
+ }
2610
+ // Recover a whole guide through the section URIs whose complete reads fit this budget.
2611
+ const fits = (sectionUri) => Buffer.byteLength(JSON.stringify(resources.readers.get(sectionUri)()), 'utf8') <= SETTINGS.maxResponseBytes;
2612
+ const sections = resources.sections.get(uri);
2613
+ const recoveries = sections.length > 1 ? sections.filter(fits) : [];
2614
+ return {
2615
+ code: -32001,
2616
+ message: `Response budget cannot fit the complete component resource. Read the listed section URIs or increase ${RESPONSE_BUDGET_SETTING} to at least ${requiredBytes} and restart.`,
2617
+ data: {
2618
+ ...data,
2619
+ recoveries,
2620
+ unrecoverableSections: sections.filter((sectionUri) => !recoveries.includes(sectionUri)).map((sectionUri) => sectionUri.slice(sectionUri.lastIndexOf('/') + 1)),
2621
+ },
2622
+ };
2623
+ }
2624
+
2625
+ function bundledResources(catalog, catalogUri, catalogSnapshot) {
2626
+ const base = catalogUri.replace('expressivecss://catalogue/', 'expressivecss://components/');
2627
+ const jsonResource = (uri, payload) => () => ({
2628
+ contents: [{
2629
+ uri,
2630
+ mimeType: 'application/json',
2631
+ text: JSON.stringify({
2632
+ ...payload,
2633
+ responseBudget: { maxBytes: SETTINGS.maxResponseBytes, delivery: 'complete', omissions: [], recoveries: [] },
2634
+ }),
2635
+ }],
2636
+ });
2637
+ const readers = new Map([[catalogUri, jsonResource(catalogUri, { schemaVersion: 1, ...catalogSnapshot })]]);
2638
+ // Whole-guide and section URIs map to the section URIs that can recover them.
2639
+ const sections = new Map();
2640
+ const guides = [];
2641
+ const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2642
+ for (const guide of [...catalog.components.values()].sort((a, b) => a.slug.localeCompare(b.slug))) {
2643
+ const uri = `${base}/${guide.slug}`;
2644
+ const identity = {
2645
+ schemaVersion: 1,
2646
+ slug: guide.slug,
2647
+ title: guide.title,
2648
+ docs: guide.sourceUrl,
2649
+ repositorySource: guide.astroSource,
2650
+ contractVersion: catalog.frameworkVersion,
2651
+ sourceHash: catalog.sourceHash,
2652
+ guideSource: catalog.guideSource,
2653
+ };
2654
+ const fields = {
2655
+ contract: guide.contract,
2656
+ syntax: { language: guide.syntax.language, example: guide.syntax.code },
2657
+ rules: guide.rules,
2658
+ options: { status: guide.options ? 'documented' : 'absent', markdown: guide.options },
2659
+ methods: { status: guide.methods ? 'documented' : 'absent', markdown: guide.methods },
2660
+ };
2661
+ const evidence = (coverageStatus) => ({
2662
+ status: provenanceBlock ? 'blocked' : 'available',
2663
+ checksPerformed: ['bundled component guide lookup'],
2664
+ evidenceSources: [`${catalog.guideSource}:${guide.file}`, 'bundled:contract.json'],
2665
+ uncheckedAreas: catalogSnapshot.uncheckedAreas,
2666
+ contractCompatibility: 'unknown',
2667
+ contractProvenance: catalog.provenance.status,
2668
+ contractProvenanceDetails: catalog.provenance,
2669
+ coverageStatus,
2670
+ blockedChecks: provenanceBlock ? [provenanceBlock] : [],
2671
+ });
2672
+ readers.set(uri, jsonResource(uri, { ...identity, ...fields, ...evidence('complete-bundled-guide') }));
2673
+ guides.push(guide);
2674
+ sections.set(uri, COMPONENT_SECTIONS.map((name) => `${uri}/${name}`));
2675
+ for (const name of COMPONENT_SECTIONS) {
2676
+ readers.set(`${uri}/${name}`, jsonResource(`${uri}/${name}`, { ...identity, section: name, [name]: fields[name], ...evidence('complete-bundled-guide-section') }));
2677
+ sections.set(`${uri}/${name}`, [`${uri}/${name}`]);
2678
+ }
2679
+ }
2680
+ return { base, readers, sections, guides };
2681
+ }
2682
+
2307
2683
  async function startServer() {
2308
2684
  const server = new McpServer(
2309
2685
  {
@@ -2318,9 +2694,9 @@ async function startServer() {
2318
2694
  );
2319
2695
 
2320
2696
  server.registerTool('setup_expert', {
2321
- description: TOOL_DESCRIPTIONS.setup_expert.description,
2697
+ description: TOOL_DESCRIPTIONS.setup_expert.description + " Responses share the operator byte budget with explicit whole-unit omissions and recovery.",
2322
2698
  inputSchema: setupSchema,
2323
- outputSchema: stageOutputSchema,
2699
+ outputSchema: guidanceOutputSchema,
2324
2700
  annotations: readAnnotations,
2325
2701
  }, setupExpertHandler);
2326
2702
 
@@ -2332,33 +2708,40 @@ async function startServer() {
2332
2708
  }, rulesEnforcerHandler);
2333
2709
 
2334
2710
  server.registerTool('creative_director', {
2335
- description: TOOL_DESCRIPTIONS.creative_director.description,
2711
+ description: TOOL_DESCRIPTIONS.creative_director.description + " Responses share the operator byte budget with explicit whole-unit omissions and recovery.",
2336
2712
  inputSchema: creativeSchema,
2337
- outputSchema: stageOutputSchema,
2713
+ outputSchema: guidanceOutputSchema,
2338
2714
  annotations: readAnnotations,
2339
2715
  }, creativeDirectorHandler);
2340
2716
 
2341
2717
  server.registerTool('page_architect', {
2342
- description: TOOL_DESCRIPTIONS.page_architect.description,
2718
+ description: TOOL_DESCRIPTIONS.page_architect.description + " Responses share the operator byte budget with explicit whole-unit omissions and recovery.",
2343
2719
  inputSchema: pageArchitectSchema,
2344
- outputSchema: stageOutputSchema,
2720
+ outputSchema: guidanceOutputSchema,
2345
2721
  annotations: readAnnotations,
2346
2722
  }, (args) => pageArchitectHandler(args, 'page_architect'));
2347
2723
 
2348
2724
  server.registerTool('page_arcjitect', {
2349
- description: TOOL_DESCRIPTIONS.page_arcjitect.description,
2725
+ description: TOOL_DESCRIPTIONS.page_arcjitect.description + " Responses share the operator byte budget with explicit whole-unit omissions and recovery.",
2350
2726
  inputSchema: pageArchitectSchema,
2351
- outputSchema: stageOutputSchema,
2727
+ outputSchema: guidanceOutputSchema,
2352
2728
  annotations: readAnnotations,
2353
2729
  }, (args) => pageArchitectHandler(args, 'page_arcjitect'));
2354
2730
 
2355
2731
  server.registerTool('component_syntax_expert', {
2356
- description: TOOL_DESCRIPTIONS.component_syntax_expert.description,
2732
+ description: TOOL_DESCRIPTIONS.component_syntax_expert.description + " Responses share the operator byte budget with explicit whole-unit omissions and recovery.",
2357
2733
  inputSchema: syntaxSchema,
2358
- outputSchema: stageOutputSchema,
2734
+ outputSchema: syntaxOutputSchema,
2359
2735
  annotations: readAnnotations,
2360
2736
  }, componentSyntaxExpertHandler);
2361
2737
 
2738
+ server.registerTool('component_catalog', {
2739
+ description: TOOL_DESCRIPTIONS.component_catalog.description + " Responses share the operator byte budget with explicit whole-unit omissions and recovery.",
2740
+ inputSchema: catalogSchema,
2741
+ outputSchema: guidanceOutputSchema,
2742
+ annotations: readAnnotations,
2743
+ }, componentCatalogHandler);
2744
+
2362
2745
  server.registerTool('quality_inspector', {
2363
2746
  description: TOOL_DESCRIPTIONS.quality_inspector.description,
2364
2747
  inputSchema: inspectSchema,
@@ -2366,7 +2749,75 @@ async function startServer() {
2366
2749
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
2367
2750
  }, qualityInspectorHandler);
2368
2751
 
2752
+ const catalog = await loadGuideCatalog(undefined);
2753
+ const catalogUri = `expressivecss://catalogue/${encodeURIComponent(catalog.frameworkVersion)}/${catalog.sourceHash}`;
2754
+ const { stage, workflowId, ...snapshot } = buildCatalogPayload(catalog, null, { workflowId: 'catalogue-resource' });
2755
+ const resources = bundledResources(catalog, catalogUri, snapshot);
2756
+ const readResource = async (uri) => {
2757
+ const reader = resources.readers.get(uri);
2758
+ if (!reader) throw new McpError(-32002, 'Resource not found. Use resources/list or resources/templates/list to discover the current bundled snapshot.');
2759
+ return reader();
2760
+ };
2761
+ server.registerResource('component_catalog', catalogUri, {
2762
+ title: 'ExpressiveCSS component catalogue',
2763
+ description: 'Compact component metadata for the current bundled snapshot only. Consumer compatibility remains unchecked.',
2764
+ mimeType: 'application/json',
2765
+ }, () => readResource(catalogUri));
2766
+ // Case-insensitive prefix completion over canonical names only; the SDK caps values at 100.
2767
+ const startingWith = (names) => (value) => names.filter((name) => name.startsWith(value.toLowerCase()));
2768
+ const slug = startingWith(resources.guides.map((guide) => guide.slug));
2769
+ server.registerResource('component_guide', new ResourceTemplate(`${resources.base}/{slug}`, {
2770
+ list: async () => ({
2771
+ resources: resources.guides.map((guide) => ({ uri: `${resources.base}/${guide.slug}`, name: guide.slug, title: `ExpressiveCSS ${guide.title} guide` })),
2772
+ }),
2773
+ complete: { slug },
2774
+ }), {
2775
+ title: 'ExpressiveCSS component guide',
2776
+ description: 'Complete contract, syntax, rules, Options and Methods for one component in the current bundled snapshot only. Use a canonical slug; consumer compatibility remains unchecked.',
2777
+ mimeType: 'application/json',
2778
+ }, (uri) => readResource(uri.href));
2779
+ server.registerResource('component_guide_section', new ResourceTemplate(`${resources.base}/{slug}/{section}`, {
2780
+ list: undefined,
2781
+ complete: { slug, section: startingWith(COMPONENT_SECTIONS) },
2782
+ }), {
2783
+ title: 'ExpressiveCSS component guide section',
2784
+ description: `One complete section of a component guide in the current bundled snapshot only. Sections: ${COMPONENT_SECTIONS.join(', ')}. Use a canonical slug; consumer compatibility remains unchecked.`,
2785
+ mimeType: 'application/json',
2786
+ }, (uri) => readResource(uri.href));
2787
+ // SDK v1 normalizes URLs and uses -32602 for missing resources. Require an
2788
+ // advertised identity verbatim and return the resource-not-found protocol code.
2789
+ server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => readResource(request.params.uri));
2790
+
2369
2791
  const transport = new StdioServerTransport();
2792
+ // Check the wire result after SDK error handling too. SDK exceptions otherwise become
2793
+ // unbounded isError results, including validation errors before our handlers run.
2794
+ const scopedRequests = new Map();
2795
+ const start = transport.start.bind(transport);
2796
+ const send = transport.send.bind(transport);
2797
+ transport.start = async () => {
2798
+ const receive = transport.onmessage;
2799
+ transport.onmessage = (message, extra) => {
2800
+ if (Object.hasOwn(message, 'id')) {
2801
+ if (message.method === 'tools/call' && GUIDANCE_TOOLS.has(message.params?.name)) scopedRequests.set(message.id, 'tool');
2802
+ if (message.method === 'resources/read') scopedRequests.set(message.id, { uri: message.params?.uri });
2803
+ }
2804
+ receive(message, extra);
2805
+ };
2806
+ await start();
2807
+ };
2808
+ transport.send = async (message, options) => {
2809
+ const kind = scopedRequests.get(message.id);
2810
+ scopedRequests.delete(message.id);
2811
+ if (kind && Object.hasOwn(message, 'result')
2812
+ && Buffer.byteLength(JSON.stringify(message.result), 'utf8') > SETTINGS.maxResponseBytes) {
2813
+ const error = kind.uri ? resourceBudgetError(kind.uri, message.result, resources)
2814
+ : { code: -32001, message: `Response budget cannot fit a truthful tool result. Increase ${RESPONSE_BUDGET_SETTING}.` };
2815
+ message = { jsonrpc: '2.0', id: message.id, error };
2816
+ }
2817
+ await send(message, options);
2818
+ };
2819
+ const close = transport.close.bind(transport);
2820
+ transport.close = async () => { try { await close(); } finally { scopedRequests.clear(); } };
2370
2821
  await server.connect(transport);
2371
2822
  console.error('ExpressiveCSS MCP server running on stdio transport.');
2372
2823
  }