@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/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +92 -0
- package/README.md +492 -7
- package/capability-roadmap.json +1 -1
- package/component-decisions.json +1 -1
- package/component-guides.json +42 -42
- package/contract.json +4 -3
- package/package.json +1 -1
- package/semantics-data.json +1 -1
- package/server.js +506 -55
- package/smoke.mjs +1233 -3
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
590
|
+
contract,
|
|
414
591
|
syntax: {
|
|
415
592
|
language: syntaxLanguage,
|
|
416
|
-
code:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|