@expressivecss/mcp-server 0.2.2 → 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,6 +19,14 @@ 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;
@@ -64,6 +73,7 @@ function configuredScripts(value) {
64
73
  }
65
74
 
66
75
  const SETTINGS = {
76
+ maxResponseBytes: configuredResponseBudget(process.env[RESPONSE_BUDGET_SETTING]),
67
77
  maxComponentResponseChars: Number(process.env.EXPRESSIVECSS_MCP_MAX_COMPONENT_RESPONSE_CHARS || DEFAULT_MAX_COMPONENT_RESPONSE_CHARS),
68
78
  maxComponentSkips: Number(process.env.EXPRESSIVECSS_MCP_MAX_COMPONENT_SKIPS || 7),
69
79
  qaMaxFiles: Number(process.env.EXPRESSIVECSS_MCP_QA_MAX_FILES || DEFAULT_QA_MAX_FILES),
@@ -170,7 +180,11 @@ const TOOL_DESCRIPTIONS = {
170
180
  },
171
181
  component_syntax_expert: {
172
182
  stage: 'Component Syntax Expert',
173
- 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.',
174
188
  },
175
189
  quality_inspector: {
176
190
  stage: 'Quality Inspector',
@@ -212,9 +226,19 @@ const pageArchitectSchema = {
212
226
 
213
227
  const syntaxSchema = {
214
228
  projectRoot: z.string().max(MAX_PROJECT_ROOT_CHARS).optional(),
229
+ detail: z.enum(['compact', 'detailed']).default('detailed'),
230
+ includeCapabilities: z.boolean().optional(),
215
231
  components: z.array(z.string().max(MAX_COMPONENT_NAME_CHARS)).max(12).default([]),
216
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(),
217
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(),
218
242
  };
219
243
 
220
244
  const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
@@ -237,6 +261,40 @@ const qualityOutputSchema = stageOutputSchema.extend({
237
261
  status: z.enum(['pass', 'warn', 'blocked', 'needs_fix']),
238
262
  inspectionEvidence: inspectionEvidenceSchema.optional(), // Disabled tools return the shared blocked envelope.
239
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
+ });
240
298
  const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
241
299
 
242
300
  const inspectSchema = {
@@ -288,13 +346,27 @@ function normalizeForMatch(value) {
288
346
 
289
347
  function extractSection(text, heading) {
290
348
  const marker = `#### ${heading}`;
291
- const start = text.indexOf(marker);
292
- if (start === -1) {
293
- 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
+ }
294
368
  }
295
- const body = text.slice(start + marker.length);
296
- const next = body.indexOf('\n#### ');
297
- return (next === -1 ? body : body.slice(0, next)).trim();
369
+ return start === -1 ? '' : lines.slice(start).join('\n').trim();
298
370
  }
299
371
 
300
372
  function extractTitle(text) {
@@ -331,16 +403,116 @@ function clampText(input, maxChars) {
331
403
  return `${text.slice(0, Math.max(0, maxChars - 40))}…(truncated)`;
332
404
  }
333
405
 
334
- function toToolResult(payload) {
406
+ function serializeToolResult(payload) {
335
407
  return {
336
408
  content: [{
337
409
  type: 'text',
338
- text: JSON.stringify(payload, null, 2),
410
+ text: JSON.stringify(payload, null, GUIDANCE_TOOLS.has(payload.stage) ? undefined : 2),
339
411
  }],
340
412
  structuredContent: payload,
413
+ ...(payload.responseBudget?.delivery === 'error' ? { isError: true } : {}),
341
414
  };
342
415
  }
343
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
+
344
516
  function buildStagePayload(tool, result, workflowId) {
345
517
  return {
346
518
  workflowId: workflowId || randomUUID().slice(0, 12),
@@ -403,6 +575,8 @@ function parseGuide(file, content) {
403
575
  const contract = extractSection(content, 'Contract');
404
576
  const syntax = extractSection(content, 'Syntax');
405
577
  const rules = extractRules(extractSection(content, 'Rules'));
578
+ const options = extractSection(content, 'Options');
579
+ const methods = extractSection(content, 'Methods');
406
580
  const syntaxCode = extractFirstCodeBlock(syntax);
407
581
  const syntaxLangMatch = syntax.match(/```\s*([a-z0-9+.-]+)/i);
408
582
  const syntaxLanguage = syntaxLangMatch ? syntaxLangMatch[1].toLowerCase() : 'html';
@@ -413,15 +587,17 @@ function parseGuide(file, content) {
413
587
  file,
414
588
  slug,
415
589
  title,
416
- contract: clampText(contract, 1_000),
590
+ contract,
417
591
  syntax: {
418
592
  language: syntaxLanguage,
419
- code: clampText(syntaxCode, Math.max(1_200, Math.floor(SETTINGS.maxComponentResponseChars / 2))),
593
+ code: syntaxCode,
420
594
  },
421
595
  rules: rules.length ? rules : [
422
596
  'Follow the component contract in the full documentation before adding optional attributes.',
423
597
  'Keep runtime-owned state in framework initialization, not in static markup values.',
424
598
  ],
599
+ options: options || null,
600
+ methods: methods || null,
425
601
  sourceUrl: docsMatch ? docsMatch[1] : null,
426
602
  astroSource: repoMatch ? `https://github.com/BaezFJ/ExpressiveCSS/blob/master/docs/src/pages/${repoMatch[1]}.astro` : null,
427
603
  text: `${title}\n${contract}\n${rules.join('\n')}`.toLowerCase(),
@@ -597,13 +773,13 @@ async function loadGuideCatalog(projectRoot) {
597
773
 
598
774
  let provenance;
599
775
 
600
- const guideDir = await resolveGuideDirectory(projectRoot);
776
+ const guideDir = projectRoot === undefined ? null : await resolveGuideDirectory(projectRoot);
601
777
  const dirStat = guideDir ? await stat(guideDir).catch(() => null) : null;
602
- const projectResolution = await resolveExpressiveVersion({
778
+ const projectResolution = projectRoot === undefined ? null : await resolveExpressiveVersion({
603
779
  projectRoot,
604
780
  contractVersion: bundledGuideCache.frameworkVersion,
605
781
  });
606
- const isFrameworkSource = projectResolution.resolutionSource === 'framework-source';
782
+ const isFrameworkSource = projectResolution?.resolutionSource === 'framework-source';
607
783
  if (dirStat?.isDirectory()) {
608
784
  const localContractPath = path.join(projectRoot, 'skills', 'expressivecss', 'references', 'contract.json');
609
785
  const localContractRead = await readInspectionFile(
@@ -650,7 +826,7 @@ async function loadGuideCatalog(projectRoot) {
650
826
 
651
827
  return {
652
828
  generatedAt: new Date().toISOString(),
653
- projectRoot: path.resolve(projectRoot),
829
+ projectRoot: projectRoot === undefined ? null : path.resolve(projectRoot),
654
830
  frameworkVersion: bundledGuideCache.frameworkVersion,
655
831
  sourceHash: bundledGuideCache.sourceHash,
656
832
  provenance,
@@ -1278,19 +1454,29 @@ function buildPageArchitecture(catalog, pageGoal, components = [], viewportTarge
1278
1454
  };
1279
1455
  }
1280
1456
 
1281
- function summarizeGuide(guide) {
1457
+ function summarizeGuide(guide, { sections, detail, includeCapabilities }) {
1282
1458
  return {
1283
1459
  file: guide.file,
1284
1460
  slug: guide.slug,
1285
1461
  title: guide.title,
1286
1462
  source: guide.sourceUrl,
1287
1463
  docs: guide.astroSource,
1288
- contract: clampText(guide.contract, 900),
1289
- syntax: {
1290
- language: guide.syntax.language,
1291
- example: clampText(guide.syntax.code, 3_000),
1292
- },
1293
- 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)),
1294
1480
  };
1295
1481
  }
1296
1482
 
@@ -1695,6 +1881,7 @@ const rulesSchemaParsed = z.object(rulesSchema);
1695
1881
  const creativeSchemaParsed = z.object(creativeSchema);
1696
1882
  const architectSchemaParsed = z.object(pageArchitectSchema);
1697
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);
1698
1885
  const qualitySchemaParsed = z.object(inspectSchema);
1699
1886
 
1700
1887
  async function setupExpertHandler(args) {
@@ -1814,7 +2001,7 @@ async function setupExpertHandler(args) {
1814
2001
  workflowId,
1815
2002
  );
1816
2003
 
1817
- return toToolResult(payload);
2004
+ return toToolResult(payload, { ...parsed, projectRoot });
1818
2005
  }
1819
2006
 
1820
2007
  async function rulesEnforcerHandler(args) {
@@ -1946,10 +2133,14 @@ async function creativeDirectorHandler(args) {
1946
2133
  }
1947
2134
 
1948
2135
  const parsed = creativeSchemaParsed.parse(args);
1949
- const workflowId = parsed.workflowId || randomUUID();
1950
2136
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
1951
2137
  const catalog = await loadGuideCatalog(projectRoot);
1952
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();
1953
2144
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
1954
2145
  const contractSafe = version.status === 'match' && !provenanceBlock;
1955
2146
 
@@ -1959,7 +2150,7 @@ async function creativeDirectorHandler(args) {
1959
2150
  : { suggestions: [], truncated: false, omittedCount: 0 };
1960
2151
  const { suggestions, truncated, omittedCount } = candidateResult;
1961
2152
 
1962
- const payload = buildStagePayload(
2153
+ return buildStagePayload(
1963
2154
  'creative_director',
1964
2155
  {
1965
2156
  goal: parsed.goal,
@@ -1997,8 +2188,6 @@ async function creativeDirectorHandler(args) {
1997
2188
  },
1998
2189
  workflowId,
1999
2190
  );
2000
-
2001
- return toToolResult(payload);
2002
2191
  }
2003
2192
 
2004
2193
  async function pageArchitectHandler(args, stage = 'page_architect') {
@@ -2072,7 +2261,7 @@ async function pageArchitectHandler(args, stage = 'page_architect') {
2072
2261
  workflowId,
2073
2262
  );
2074
2263
 
2075
- return toToolResult(payload);
2264
+ return toToolResult(payload, { ...parsed, projectRoot });
2076
2265
  }
2077
2266
 
2078
2267
  async function componentSyntaxExpertHandler(args) {
@@ -2081,10 +2270,15 @@ async function componentSyntaxExpertHandler(args) {
2081
2270
  }
2082
2271
 
2083
2272
  const parsed = syntaxSchemaParsed.parse(args);
2084
- const workflowId = parsed.workflowId || randomUUID();
2085
2273
  const projectRoot = resolveProjectRoot(parsed.projectRoot);
2086
2274
  const catalog = await loadGuideCatalog(projectRoot);
2087
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();
2088
2282
  const provenanceBlock = provenanceBlockReason(catalog.provenance.status);
2089
2283
 
2090
2284
  const capabilitySafe = version.status === 'match' && !provenanceBlock && CAPABILITY_ROADMAP.frameworkVersion === catalog.frameworkVersion;
@@ -2092,7 +2286,7 @@ async function componentSyntaxExpertHandler(args) {
2092
2286
  const found = [];
2093
2287
  const missing = [];
2094
2288
 
2095
- for (const component of requested) {
2289
+ for (const [requestIndex, component] of requested.entries()) {
2096
2290
  const guide = findGuideByName(catalog, component, false);
2097
2291
  if (!guide) {
2098
2292
  const skipLimit = Math.min(Math.max(SETTINGS.maxComponentSkips, 1), 20);
@@ -2101,13 +2295,19 @@ async function componentSyntaxExpertHandler(args) {
2101
2295
  nearest: nearestMatches(catalog, component, skipLimit).map((row) => row.slug),
2102
2296
  });
2103
2297
  } else {
2104
- 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
+ });
2105
2303
  }
2106
2304
  }
2107
2305
 
2108
- const payload = buildStagePayload(
2306
+ return buildStagePayload(
2109
2307
  'component_syntax_expert',
2110
2308
  {
2309
+ detail: parsed.detail,
2310
+ includeCapabilities,
2111
2311
  foundCount: found.length,
2112
2312
  foundations: capabilitySafe ? parsed.foundations.map((slug) => CAPABILITIES_BY_SLUG.get(slug)) : [],
2113
2313
  capabilityEvidence: { status: capabilitySafe ? 'bundled-review-snapshot' : 'blocked', basis: CAPABILITY_ROADMAP.basis, browserRun: capabilitySafe ? CAPABILITY_ROADMAP.browserRun : null },
@@ -2142,8 +2342,74 @@ async function componentSyntaxExpertHandler(args) {
2142
2342
  },
2143
2343
  workflowId,
2144
2344
  );
2345
+ }
2145
2346
 
2146
- 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);
2147
2413
  }
2148
2414
 
2149
2415
  async function qualityInspectorHandler(args) {
@@ -2315,6 +2581,105 @@ async function qualityInspectorHandler(args) {
2315
2581
  return toToolResult(payload);
2316
2582
  }
2317
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
+
2318
2683
  async function startServer() {
2319
2684
  const server = new McpServer(
2320
2685
  {
@@ -2329,9 +2694,9 @@ async function startServer() {
2329
2694
  );
2330
2695
 
2331
2696
  server.registerTool('setup_expert', {
2332
- 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.",
2333
2698
  inputSchema: setupSchema,
2334
- outputSchema: stageOutputSchema,
2699
+ outputSchema: guidanceOutputSchema,
2335
2700
  annotations: readAnnotations,
2336
2701
  }, setupExpertHandler);
2337
2702
 
@@ -2343,33 +2708,40 @@ async function startServer() {
2343
2708
  }, rulesEnforcerHandler);
2344
2709
 
2345
2710
  server.registerTool('creative_director', {
2346
- 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.",
2347
2712
  inputSchema: creativeSchema,
2348
- outputSchema: stageOutputSchema,
2713
+ outputSchema: guidanceOutputSchema,
2349
2714
  annotations: readAnnotations,
2350
2715
  }, creativeDirectorHandler);
2351
2716
 
2352
2717
  server.registerTool('page_architect', {
2353
- 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.",
2354
2719
  inputSchema: pageArchitectSchema,
2355
- outputSchema: stageOutputSchema,
2720
+ outputSchema: guidanceOutputSchema,
2356
2721
  annotations: readAnnotations,
2357
2722
  }, (args) => pageArchitectHandler(args, 'page_architect'));
2358
2723
 
2359
2724
  server.registerTool('page_arcjitect', {
2360
- 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.",
2361
2726
  inputSchema: pageArchitectSchema,
2362
- outputSchema: stageOutputSchema,
2727
+ outputSchema: guidanceOutputSchema,
2363
2728
  annotations: readAnnotations,
2364
2729
  }, (args) => pageArchitectHandler(args, 'page_arcjitect'));
2365
2730
 
2366
2731
  server.registerTool('component_syntax_expert', {
2367
- 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.",
2368
2733
  inputSchema: syntaxSchema,
2369
- outputSchema: stageOutputSchema,
2734
+ outputSchema: syntaxOutputSchema,
2370
2735
  annotations: readAnnotations,
2371
2736
  }, componentSyntaxExpertHandler);
2372
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
+
2373
2745
  server.registerTool('quality_inspector', {
2374
2746
  description: TOOL_DESCRIPTIONS.quality_inspector.description,
2375
2747
  inputSchema: inspectSchema,
@@ -2377,7 +2749,75 @@ async function startServer() {
2377
2749
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
2378
2750
  }, qualityInspectorHandler);
2379
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
+
2380
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(); } };
2381
2821
  await server.connect(transport);
2382
2822
  console.error('ExpressiveCSS MCP server running on stdio transport.');
2383
2823
  }